diff --git a/agent/app/core/task_runner.py b/agent/app/core/task_runner.py index 6bef26c..50c7cba 100644 --- a/agent/app/core/task_runner.py +++ b/agent/app/core/task_runner.py @@ -38,6 +38,7 @@ class TaskRunner: manifest["appName"], manifest["version"], manifest_hash, + manifest.get("openUrl"), ) self.repository.update_task( task_id, diff --git a/agent/app/models/schemas.py b/agent/app/models/schemas.py index 7ad290e..3c6150f 100644 --- a/agent/app/models/schemas.py +++ b/agent/app/models/schemas.py @@ -181,6 +181,7 @@ class AppManifest(CamelModel): app_id: str = Field(alias="appId") app_name: str = Field(alias="appName") version: str + open_url: str | None = Field(default=None, alias="openUrl") architecture: str = "amd64" components: list[dict[str, Any]] signature: str | None = None diff --git a/agent/app/storage/database.py b/agent/app/storage/database.py index b3b5c2f..10a92ee 100644 --- a/agent/app/storage/database.py +++ b/agent/app/storage/database.py @@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS installed_apps ( app_id TEXT PRIMARY KEY, app_name TEXT NOT NULL, version TEXT NOT NULL, + open_url TEXT, manifest_hash TEXT, status TEXT NOT NULL, installed_at TEXT NOT NULL, @@ -86,6 +87,15 @@ CREATE TABLE IF NOT EXISTS agent_config ( """ +def _ensure_column(connection: sqlite3.Connection, table: str, column: str, definition: str) -> None: + columns = { + row["name"] + for row in connection.execute(f"PRAGMA table_info({table})").fetchall() + } + if column not in columns: + connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}") + + def get_connection() -> sqlite3.Connection: connection = sqlite3.connect(settings.db_path, timeout=30) connection.row_factory = sqlite3.Row @@ -99,4 +109,4 @@ def initialize_database() -> None: settings.log_dir.mkdir(parents=True, exist_ok=True) with get_connection() as connection: connection.executescript(SCHEMA) - + _ensure_column(connection, "installed_apps", "open_url", "TEXT") diff --git a/agent/app/storage/repository.py b/agent/app/storage/repository.py index 88fdb3e..a7d9fd6 100644 --- a/agent/app/storage/repository.py +++ b/agent/app/storage/repository.py @@ -160,7 +160,7 @@ class Repository: with get_connection() as connection: rows = connection.execute( """ - SELECT app_id, app_name, version, manifest_hash, status, installed_at, updated_at + SELECT app_id, app_name, version, open_url, manifest_hash, status, installed_at, updated_at FROM installed_apps ORDER BY app_name ASC """ @@ -173,22 +173,24 @@ class Repository: app_name: str, version: str, manifest_hash: str | None, + open_url: str | None = None, status: str = "installed", ) -> None: now = utc_now() with get_connection() as connection: connection.execute( """ - INSERT INTO installed_apps (app_id, app_name, version, manifest_hash, status, installed_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO installed_apps (app_id, app_name, version, open_url, manifest_hash, status, installed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(app_id) DO UPDATE SET app_name = excluded.app_name, version = excluded.version, + open_url = excluded.open_url, manifest_hash = excluded.manifest_hash, status = excluded.status, updated_at = excluded.updated_at """, - (app_id, app_name, version, manifest_hash, status, now, now), + (app_id, app_name, version, open_url, manifest_hash, status, now, now), ) def delete_installed_app(self, app_id: str) -> None: @@ -252,4 +254,3 @@ class Repository: def export_manifest_hash(self, manifest: dict[str, Any]) -> str: return json.dumps(manifest, sort_keys=True, separators=(",", ":")) - diff --git a/web-server/database/02_schema.sql b/web-server/database/02_schema.sql index 707a19c..397443d 100644 --- a/web-server/database/02_schema.sql +++ b/web-server/database/02_schema.sql @@ -125,6 +125,7 @@ CREATE TABLE dbo.Applications Status NVARCHAR(50) NOT NULL CONSTRAINT DF_Applications_Status DEFAULT N'Draft', Notes NVARCHAR(500) NULL, + OpenUrl NVARCHAR(500) NULL, CONSTRAINT FK_Applications_CreatedByUser FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id), CONSTRAINT CK_Applications_Status CHECK (Status IN (N'Draft', N'Released', N'Archived')), diff --git a/web-server/database/03_views.sql b/web-server/database/03_views.sql index ba50af1..f8f7c1a 100644 --- a/web-server/database/03_views.sql +++ b/web-server/database/03_views.sql @@ -76,6 +76,7 @@ SELECT a.Description, a.Status, a.Notes, + a.OpenUrl, a.CreatedAt, a.UpdatedAt, a.CreatedByUserId, @@ -94,6 +95,7 @@ GROUP BY a.Description, a.Status, a.Notes, + a.OpenUrl, a.CreatedAt, a.UpdatedAt, a.CreatedByUserId, @@ -108,6 +110,7 @@ SELECT a.AppCode, a.AppName, a.AppVersion, + a.OpenUrl AS AppOpenUrl, ap.PackageId, p.PackageCode, p.PackageName, diff --git a/web-server/public/js/app.js b/web-server/public/js/app.js index 2744dd9..7a8d65b 100644 --- a/web-server/public/js/app.js +++ b/web-server/public/js/app.js @@ -412,6 +412,7 @@ name: trigger.dataset.appName || '', version: trigger.dataset.appVersion || '', status: trigger.dataset.appStatus || 'Draft', + openUrl: trigger.dataset.appOpenUrl || '', notes: trigger.dataset.appNotes || '', packages: parseJsonAttribute(trigger.dataset.appPackages, []) }; @@ -446,6 +447,7 @@ setEditAppField(form, 'appName', app.name); setEditAppField(form, 'appVersion', app.version); setEditAppField(form, 'status', app.status); + setEditAppField(form, 'openUrl', app.openUrl); setEditAppField(form, 'notes', app.notes); form.querySelectorAll('[data-edit-app-package]').forEach((checkbox) => { diff --git a/web-server/server.js b/web-server/server.js index 6346c10..464e16b 100644 --- a/web-server/server.js +++ b/web-server/server.js @@ -844,6 +844,24 @@ function normalizeApplicationStatus(value) { return ['Draft', 'Released', 'Archived'].includes(value) ? value : 'Draft'; } +function normalizeApplicationOpenUrl(value) { + const text = String(value || '').trim(); + if (!text) return ''; + + const candidate = /^https?:\/\//i.test(text) ? text : `http://${text}`; + + try { + const parsed = new URL(candidate); + if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) { + return null; + } + + return parsed.href.replace(/\/+$/, ''); + } catch { + return null; + } +} + function isInstallerIdentifier(value) { return installerIdentifierPattern.test(String(value || '').trim()); } @@ -1297,7 +1315,8 @@ app.get('/api/apps', asyncRoute(async (req, res) => { appName: application.name, version: application.version, status: application.status, - packageCount: application.packageCount + packageCount: application.packageCount, + openUrl: application.openUrl })) }); })); @@ -1316,6 +1335,7 @@ app.get('/api/apps/:appCode', asyncRoute(async (req, res) => { version: application.version, status: application.status, packageCount: application.packageCount, + openUrl: application.openUrl, packages: application.packages }); })); @@ -1839,7 +1859,7 @@ app.get('/applications', asyncRoute(async (req, res) => { 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'], + ['AppCode', 'AppName', 'AppVersion', 'Status', 'CreatedAt', 'CreatedBy', 'PackageCount', 'Packages', 'OpenUrl', 'Notes'], ...applications.map((application) => [ application.code, application.name, @@ -1849,6 +1869,7 @@ app.get('/applications/export.csv', asyncRoute(async (req, res) => { application.createdBy, application.packageCount, application.packages.map((packageItem) => `${packageItem.code}@${packageItem.selectedVersion}`).join('; '), + application.openUrl, application.notes ]) ]; @@ -1860,12 +1881,18 @@ app.post('/applications', asyncRoute(async (req, res) => { const appCode = String(req.body.appCode || '').trim(); const appName = String(req.body.appName || '').trim(); const appVersion = String(req.body.appVersion || '').trim(); + const openUrl = normalizeApplicationOpenUrl(req.body.openUrl); if (!appCode || !appName || !appVersion) { redirectWithNotice(res, '/builder', 'warning', 'Vui lòng nhập App code, App name và App version.'); return; } + if (openUrl === null) { + redirectWithNotice(res, '/builder', 'warning', 'Open URL khong hop le. Hay nhap URL http/https, vi du http://127.0.0.1:5000.'); + return; + } + if (!isInstallerIdentifier(appCode)) { redirectWithNotice(res, '/builder', 'warning', `App code khong hop le. ${installerIdentifierHint}`); return; @@ -1882,6 +1909,7 @@ app.post('/applications', asyncRoute(async (req, res) => { appName, appVersion, notes: req.body.notes, + openUrl, status: normalizeApplicationStatus(req.body.status), createdByUserId: req.currentUser.id, packages: getSelectedApplicationPackages(req.body) @@ -1904,12 +1932,18 @@ app.post('/applications/:id/edit', asyncRoute(async (req, res) => { const appCode = String(req.body.appCode || '').trim(); const appName = String(req.body.appName || '').trim(); const appVersion = String(req.body.appVersion || '').trim(); + const openUrl = normalizeApplicationOpenUrl(req.body.openUrl); if (!appCode || !appName || !appVersion) { redirectWithNotice(res, returnTo, 'warning', 'Vui lòng nhập App code, App name và App version.'); return; } + if (openUrl === null) { + redirectWithNotice(res, returnTo, 'warning', 'Open URL khong hop le. Hay nhap URL http/https, vi du http://127.0.0.1:5000.'); + return; + } + if (!isInstallerIdentifier(appCode)) { redirectWithNotice(res, returnTo, 'warning', `App code khong hop le. ${installerIdentifierHint}`); return; @@ -1927,6 +1961,7 @@ app.post('/applications/:id/edit', asyncRoute(async (req, res) => { appName, appVersion, notes: req.body.notes, + openUrl, status: normalizeApplicationStatus(req.body.status), packages: getSelectedApplicationPackages(req.body) }); diff --git a/web-server/src/repository.js b/web-server/src/repository.js index b71c16a..f07ea74 100644 --- a/web-server/src/repository.js +++ b/web-server/src/repository.js @@ -9,6 +9,7 @@ const PLACEHOLDER_PASSWORD_HASH = 'ui-placeholder-password-hash'; const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPIRES_MS || 1000 * 60 * 60 * 24); let emailConfirmationSchemaPromise; +let applicationOpenUrlSchemaPromise; function toDateInputValue(value) { const date = value ? new Date(value) : new Date(); @@ -185,6 +186,86 @@ async function ensureEmailConfirmationSchema() { return emailConfirmationSchemaPromise; } +async function ensureApplicationOpenUrlSchema() { + if (!applicationOpenUrlSchemaPromise) { + applicationOpenUrlSchemaPromise = getPool().then((pool) => pool.request().query(` + IF COL_LENGTH(N'dbo.Applications', N'OpenUrl') IS NULL + BEGIN + ALTER TABLE dbo.Applications + ADD OpenUrl NVARCHAR(500) NULL; + END; + + EXEC(N' +CREATE OR ALTER VIEW dbo.vw_ApplicationList +AS +SELECT + a.Id, + a.AppCode, + a.AppName, + a.AppVersion, + a.Description, + a.Status, + a.Notes, + a.OpenUrl, + a.CreatedAt, + a.UpdatedAt, + a.CreatedByUserId, + u.Username AS CreatedByUsername, + COUNT_BIG(ap.Id) AS PackageCount +FROM dbo.Applications AS a +INNER JOIN dbo.Users AS u + ON u.Id = a.CreatedByUserId +LEFT JOIN dbo.ApplicationPackages AS ap + ON ap.ApplicationId = a.Id +GROUP BY + a.Id, + a.AppCode, + a.AppName, + a.AppVersion, + a.Description, + a.Status, + a.Notes, + a.OpenUrl, + a.CreatedAt, + a.UpdatedAt, + a.CreatedByUserId, + u.Username; +'); + + EXEC(N' +CREATE OR ALTER VIEW dbo.vw_ApplicationPackageDetails +AS +SELECT + ap.Id, + ap.ApplicationId, + a.AppCode, + a.AppName, + a.AppVersion, + a.OpenUrl AS AppOpenUrl, + ap.PackageId, + p.PackageCode, + p.PackageName, + p.PackageType, + ap.SelectedVersionId, + pv.Version AS SelectedVersion, + pv.FilePath, + pv.DockerImage, + ap.AddedAt, + ap.Notes +FROM dbo.ApplicationPackages AS ap +INNER JOIN dbo.Applications AS a + ON a.Id = ap.ApplicationId +INNER JOIN dbo.Packages AS p + ON p.Id = ap.PackageId +LEFT JOIN dbo.PackageVersions AS pv + ON pv.Id = ap.SelectedVersionId; +'); + `)); + } + + return applicationOpenUrlSchemaPromise; +} + function normalizePackageStatus(isActive) { return isActive ? 'Active' : 'Archived'; } @@ -242,6 +323,7 @@ function mapApplicationRow(row) { createdAt: formatDate(row.CreatedAt), createdBy: row.CreatedByUsername || '', notes: row.Notes || row.Description || '', + openUrl: row.OpenUrl || '', packageCount: Number(row.PackageCount || 0), packages: [] }; @@ -251,6 +333,7 @@ function mapApplicationPackageRow(row) { return { id: String(row.Id), applicationId: String(row.ApplicationId), + appOpenUrl: row.AppOpenUrl || '', packageId: String(row.PackageId), code: row.PackageCode, name: row.PackageName, @@ -759,6 +842,7 @@ async function getPackageById(id) { } async function listApplications() { + await ensureApplicationOpenUrlSchema(); const pool = await getPool(); const result = await pool.request().query(` SELECT * @@ -771,6 +855,7 @@ async function listApplications() { } async function listApplicationPackages(applicationId) { + await ensureApplicationOpenUrlSchema(); const pool = await getPool(); const request = pool.request(); let whereClause = ''; @@ -816,6 +901,7 @@ async function attachApplicationPackages(applications) { } async function getApplicationById(id) { + await ensureApplicationOpenUrlSchema(); const pool = await getPool(); const appResult = await pool.request() .input('Id', sql.NVarChar(100), id) @@ -834,12 +920,13 @@ async function getApplicationById(id) { } async function getApplicationManifest(appCode, version, baseUrl) { + await ensureApplicationOpenUrlSchema(); const pool = await getPool(); const appResult = await pool.request() .input('AppCode', sql.NVarChar(100), String(appCode || '').trim()) .input('AppVersion', sql.NVarChar(50), String(version || '').trim()) .query(` - SELECT TOP (1) Id, AppCode, AppName, AppVersion + SELECT TOP (1) Id, AppCode, AppName, AppVersion, OpenUrl FROM dbo.Applications WHERE (CONVERT(NVARCHAR(36), Id) = @AppCode OR AppCode = @AppCode) AND AppVersion = @AppVersion @@ -911,6 +998,7 @@ async function getApplicationManifest(appCode, version, baseUrl) { appId: String(appRow.Id), appName: appRow.AppName, version: appRow.AppVersion, + openUrl: appRow.OpenUrl || '', architecture: 'amd64', components }; @@ -1199,6 +1287,7 @@ async function deletePackageVersion(packageVersionId) { } async function createApplication(input) { + await ensureApplicationOpenUrlSchema(); const pool = await getPool(); const transaction = new sql.Transaction(pool); @@ -1210,12 +1299,13 @@ async function createApplication(input) { .input('AppName', sql.NVarChar(200), input.appName) .input('AppVersion', sql.NVarChar(50), input.appVersion) .input('Notes', sql.NVarChar(500), input.notes || null) + .input('OpenUrl', sql.NVarChar(500), input.openUrl || null) .input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId) .input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status)) .query(` - INSERT dbo.Applications (AppCode, AppName, AppVersion, Notes, CreatedByUserId, Status) + INSERT dbo.Applications (AppCode, AppName, AppVersion, Notes, OpenUrl, CreatedByUserId, Status) OUTPUT inserted.Id - VALUES (@AppCode, @AppName, @AppVersion, @Notes, @CreatedByUserId, @Status); + VALUES (@AppCode, @AppName, @AppVersion, @Notes, @OpenUrl, @CreatedByUserId, @Status); `); const applicationId = String(appResult.recordset[0].Id); @@ -1244,6 +1334,7 @@ async function createApplication(input) { } async function updateApplication(input) { + await ensureApplicationOpenUrlSchema(); const pool = await getPool(); const transaction = new sql.Transaction(pool); @@ -1270,6 +1361,7 @@ async function updateApplication(input) { .input('AppName', sql.NVarChar(200), input.appName) .input('AppVersion', sql.NVarChar(50), input.appVersion) .input('Notes', sql.NVarChar(500), input.notes || null) + .input('OpenUrl', sql.NVarChar(500), input.openUrl || null) .input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status)) .query(` UPDATE dbo.Applications @@ -1277,6 +1369,7 @@ async function updateApplication(input) { AppName = @AppName, AppVersion = @AppVersion, Notes = @Notes, + OpenUrl = @OpenUrl, Status = @Status, UpdatedAt = SYSUTCDATETIME() OUTPUT inserted.Id diff --git a/web-server/views/application-detail.ejs b/web-server/views/application-detail.ejs index d0aca5a..dee2dbf 100644 --- a/web-server/views/application-detail.ejs +++ b/web-server/views/application-detail.ejs @@ -17,6 +17,7 @@ data-app-name="<%= application.name %>" data-app-version="<%= application.version %>" data-app-status="<%= application.status %>" + data-app-open-url="<%= application.openUrl %>" data-app-notes="<%= application.notes %>" data-app-packages="<%= JSON.stringify(application.packages.map((pkg) => ({ packageId: pkg.packageId, selectedVersionId: pkg.selectedVersionId }))) %>" > @@ -53,6 +54,7 @@