fhirsmith 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/FHIRsmith.png +0 -0
- package/README.md +277 -0
- package/config-template.json +144 -0
- package/library/folder-setup.js +58 -0
- package/library/html-server.js +166 -0
- package/library/html.js +835 -0
- package/library/i18nsupport.js +259 -0
- package/library/languages.js +779 -0
- package/library/logger-telnet.js +205 -0
- package/library/logger.js +279 -0
- package/library/package-manager.js +876 -0
- package/library/utilities.js +196 -0
- package/library/version-utilities.js +1056 -0
- package/npmprojector/config-example.json +13 -0
- package/npmprojector/indexer.js +394 -0
- package/npmprojector/npmprojector.js +395 -0
- package/npmprojector/readme.md +174 -0
- package/npmprojector/watcher.js +335 -0
- package/package.json +119 -0
- package/packages/package-crawler.js +846 -0
- package/packages/packages-template.html +126 -0
- package/packages/packages.js +2838 -0
- package/passwords.ini +2 -0
- package/publisher/publisher-template.html +208 -0
- package/publisher/publisher.js +2167 -0
- package/publisher/task-draft.js +458 -0
- package/registry/api.js +735 -0
- package/registry/crawler.js +637 -0
- package/registry/model.js +513 -0
- package/registry/readme.md +243 -0
- package/registry/registry-data.json +121015 -0
- package/registry/registry-template.html +126 -0
- package/registry/registry.js +1395 -0
- package/registry/test-runner.js +237 -0
- package/root-template.html +124 -0
- package/server.js +524 -0
- package/shl/private-key.pem +5 -0
- package/shl/public-key.pem +18 -0
- package/shl/shl.js +1125 -0
- package/shl/vhl.js +69 -0
- package/static/FHIRsmith128.png +0 -0
- package/static/FHIRsmith16.png +0 -0
- package/static/FHIRsmith32.png +0 -0
- package/static/FHIRsmith64.png +0 -0
- package/static/assets/css/bootstrap-fhir.css +5302 -0
- package/static/assets/css/bootstrap-glyphicons.css +2 -0
- package/static/assets/css/bootstrap.css +4097 -0
- package/static/assets/css/jquery-ui.css +523 -0
- package/static/assets/css/jquery-ui.structure.css +863 -0
- package/static/assets/css/jquery-ui.structure.min.css +5 -0
- package/static/assets/css/jquery-ui.theme.css +439 -0
- package/static/assets/css/jquery-ui.theme.min.css +5 -0
- package/static/assets/css/jquery.ui.all.css +7 -0
- package/static/assets/css/modules.css +18 -0
- package/static/assets/css/project.css +367 -0
- package/static/assets/css/pygments-manni.css +66 -0
- package/static/assets/css/tags.css +74 -0
- package/static/assets/css/xml.css +2 -0
- package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
- package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
- package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
- package/static/assets/ico/favicon.ico +0 -0
- package/static/assets/ico/favicon.png +0 -0
- package/static/assets/images/fhir-logo-www.png +0 -0
- package/static/assets/images/fhir-logo.png +0 -0
- package/static/assets/images/hl7-logo.png +0 -0
- package/static/assets/images/logo_ansinew.jpg +0 -0
- package/static/assets/images/search.png +0 -0
- package/static/assets/images/stripe.png +0 -0
- package/static/assets/images/target.png +0 -0
- package/static/assets/images/tx-registry-root.gif +0 -0
- package/static/assets/images/tx-registry.png +0 -0
- package/static/assets/images/tx-server.png +0 -0
- package/static/assets/images/tx-version.png +0 -0
- package/static/assets/js/bootstrap.min.js +6 -0
- package/static/assets/js/fhir-gw.js +259 -0
- package/static/assets/js/fhir.js +2 -0
- package/static/assets/js/html5shiv.js +8 -0
- package/static/assets/js/jcookie.js +96 -0
- package/static/assets/js/jquery-ui.min.js +6 -0
- package/static/assets/js/jquery.js +10716 -0
- package/static/assets/js/jquery.min.js +2 -0
- package/static/assets/js/jquery.ui.core.js +314 -0
- package/static/assets/js/jquery.ui.draggable.js +825 -0
- package/static/assets/js/jquery.ui.mouse.js +162 -0
- package/static/assets/js/jquery.ui.resizable.js +842 -0
- package/static/assets/js/jquery.ui.widget.js +268 -0
- package/static/assets/js/json2.js +487 -0
- package/static/assets/js/jtip.js +97 -0
- package/static/assets/js/respond.min.js +6 -0
- package/static/assets/js/statuspage.js +70 -0
- package/static/assets/js/xml.js +2 -0
- package/static/dist/js/bootstrap.js +1964 -0
- package/static/favicon.png +0 -0
- package/static/fhir.css +626 -0
- package/static/icon-fhir-16.png +0 -0
- package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
- package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
- package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
- package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
- package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
- package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
- package/static/images/ui-icons_222222_256x240.png +0 -0
- package/static/images/ui-icons_228ef1_256x240.png +0 -0
- package/static/images/ui-icons_ef8c08_256x240.png +0 -0
- package/static/images/ui-icons_ffd27a_256x240.png +0 -0
- package/static/images/ui-icons_ffffff_256x240.png +0 -0
- package/static/js/jquery.effects.blind.js +49 -0
- package/static/js/jquery.effects.bounce.js +78 -0
- package/static/js/jquery.effects.clip.js +54 -0
- package/static/js/jquery.effects.core.js +763 -0
- package/static/js/jquery.effects.drop.js +50 -0
- package/static/js/jquery.effects.explode.js +79 -0
- package/static/js/jquery.effects.fade.js +32 -0
- package/static/js/jquery.effects.fold.js +56 -0
- package/static/js/jquery.effects.highlight.js +50 -0
- package/static/js/jquery.effects.pulsate.js +51 -0
- package/static/js/jquery.effects.scale.js +178 -0
- package/static/js/jquery.effects.shake.js +57 -0
- package/static/js/jquery.effects.slide.js +50 -0
- package/static/js/jquery.effects.transfer.js +45 -0
- package/static/js/jquery.ui.accordion.js +611 -0
- package/static/js/jquery.ui.autocomplete.js +612 -0
- package/static/js/jquery.ui.button.js +416 -0
- package/static/js/jquery.ui.datepicker.js +1823 -0
- package/static/js/jquery.ui.dialog.js +878 -0
- package/static/js/jquery.ui.droppable.js +296 -0
- package/static/js/jquery.ui.position.js +252 -0
- package/static/js/jquery.ui.progressbar.js +109 -0
- package/static/js/jquery.ui.selectable.js +266 -0
- package/static/js/jquery.ui.slider.js +666 -0
- package/static/js/jquery.ui.sortable.js +1077 -0
- package/static/js/jquery.ui.tabs.js +758 -0
- package/stats.js +80 -0
- package/test-cache/vsac/vsac-valuesets.db +0 -0
- package/token/nginx_passport_setup.md +383 -0
- package/token/security_guide.md +294 -0
- package/token/token-template.html +330 -0
- package/token/token.js +1300 -0
- package/translations/Messages.properties +1510 -0
- package/translations/Messages_ar.properties +1399 -0
- package/translations/Messages_de.properties +836 -0
- package/translations/Messages_es.properties +737 -0
- package/translations/Messages_fr.properties +1 -0
- package/translations/Messages_ja.properties +893 -0
- package/translations/Messages_nl.properties +1357 -0
- package/translations/Messages_pt.properties +1302 -0
- package/translations/Messages_ru.properties +1 -0
- package/translations/Messages_uz.properties +1 -0
- package/translations/Messages_zh.properties +1 -0
- package/translations/rendering-phrases.properties +1128 -0
- package/translations/rendering-phrases_ar.properties +1091 -0
- package/translations/rendering-phrases_de.properties +6 -0
- package/translations/rendering-phrases_es.properties +6 -0
- package/translations/rendering-phrases_fr.properties +624 -0
- package/translations/rendering-phrases_ja.properties +21 -0
- package/translations/rendering-phrases_nl.properties +970 -0
- package/translations/rendering-phrases_pt.properties +1020 -0
- package/translations/rendering-phrases_ru.properties +1094 -0
- package/translations/rendering-phrases_uz.properties +1 -0
- package/translations/rendering-phrases_zh.properties +1 -0
- package/tx/README.md +418 -0
- package/tx/cm/cm-api.js +110 -0
- package/tx/cm/cm-database.js +735 -0
- package/tx/cm/cm-package.js +325 -0
- package/tx/cs/cs-api.js +789 -0
- package/tx/cs/cs-areacode.js +615 -0
- package/tx/cs/cs-country.js +1110 -0
- package/tx/cs/cs-cpt.js +785 -0
- package/tx/cs/cs-cs.js +1579 -0
- package/tx/cs/cs-currency.js +539 -0
- package/tx/cs/cs-db.js +1321 -0
- package/tx/cs/cs-hgvs.js +329 -0
- package/tx/cs/cs-lang.js +465 -0
- package/tx/cs/cs-loinc.js +1485 -0
- package/tx/cs/cs-mimetypes.js +238 -0
- package/tx/cs/cs-ndc.js +704 -0
- package/tx/cs/cs-omop.js +1025 -0
- package/tx/cs/cs-provider-api.js +43 -0
- package/tx/cs/cs-provider-list.js +37 -0
- package/tx/cs/cs-rxnorm.js +808 -0
- package/tx/cs/cs-snomed.js +1102 -0
- package/tx/cs/cs-ucum.js +514 -0
- package/tx/cs/cs-unii.js +271 -0
- package/tx/cs/cs-uri.js +218 -0
- package/tx/cs/cs-usstates.js +305 -0
- package/tx/dev.fhir.org.yml +14 -0
- package/tx/fixtures/test-cases-setup.json +18 -0
- package/tx/fixtures/test-cases.yml +16 -0
- package/tx/html/codesystem-operations.liquid +25 -0
- package/tx/html/home-metrics.liquid +247 -0
- package/tx/html/operations-form.liquid +148 -0
- package/tx/html/search-form.liquid +62 -0
- package/tx/html/tx-template.html +133 -0
- package/tx/html/valueset-operations.liquid +54 -0
- package/tx/importers/atc-to-fhir.js +316 -0
- package/tx/importers/import-loinc.module.js +1536 -0
- package/tx/importers/import-ndc.module.js +1088 -0
- package/tx/importers/import-rxnorm.module.js +898 -0
- package/tx/importers/import-sct.module.js +2457 -0
- package/tx/importers/import-unii.module.js +601 -0
- package/tx/importers/readme.md +453 -0
- package/tx/importers/subset-loinc.module.js +1081 -0
- package/tx/importers/subset-rxnorm.module.js +938 -0
- package/tx/importers/tx-import-base.js +351 -0
- package/tx/importers/tx-import-settings.js +310 -0
- package/tx/importers/tx-import.js +357 -0
- package/tx/library/canonical-resource.js +88 -0
- package/tx/library/capabilitystatement.js +292 -0
- package/tx/library/codesystem.js +774 -0
- package/tx/library/conceptmap.js +568 -0
- package/tx/library/designations.js +932 -0
- package/tx/library/errors.js +77 -0
- package/tx/library/extensions.js +117 -0
- package/tx/library/namingsystem.js +322 -0
- package/tx/library/operation-outcome.js +127 -0
- package/tx/library/parameters.js +105 -0
- package/tx/library/renderer.js +1559 -0
- package/tx/library/terminologycapabilities.js +418 -0
- package/tx/library/ucum-parsers.js +1029 -0
- package/tx/library/ucum-service.js +370 -0
- package/tx/library/ucum-types.js +1099 -0
- package/tx/library/valueset.js +543 -0
- package/tx/library.js +676 -0
- package/tx/ocl/cm-ocl.js +106 -0
- package/tx/ocl/cs-ocl.js +39 -0
- package/tx/ocl/vs-ocl.js +105 -0
- package/tx/operation-context.js +568 -0
- package/tx/params.js +613 -0
- package/tx/provider.js +403 -0
- package/tx/sct/ecl.js +1560 -0
- package/tx/sct/expressions.js +2077 -0
- package/tx/sct/structures.js +1396 -0
- package/tx/tx-html.js +1063 -0
- package/tx/tx.fhir.org.yml +39 -0
- package/tx/tx.js +927 -0
- package/tx/vs/vs-api.js +112 -0
- package/tx/vs/vs-database.js +786 -0
- package/tx/vs/vs-package.js +358 -0
- package/tx/vs/vs-vsac.js +366 -0
- package/tx/workers/batch-validate.js +129 -0
- package/tx/workers/batch.js +361 -0
- package/tx/workers/closure.js +32 -0
- package/tx/workers/expand.js +1845 -0
- package/tx/workers/lookup.js +407 -0
- package/tx/workers/metadata.js +467 -0
- package/tx/workers/operations.js +34 -0
- package/tx/workers/read.js +164 -0
- package/tx/workers/search.js +384 -0
- package/tx/workers/subsumes.js +334 -0
- package/tx/workers/translate.js +492 -0
- package/tx/workers/validate.js +2504 -0
- package/tx/workers/worker.js +904 -0
- package/tx/xml/capabilitystatement-xml.js +63 -0
- package/tx/xml/codesystem-xml.js +62 -0
- package/tx/xml/conceptmap-xml.js +65 -0
- package/tx/xml/namingsystem-xml.js +65 -0
- package/tx/xml/operationoutcome-xml.js +127 -0
- package/tx/xml/parameters-xml.js +312 -0
- package/tx/xml/terminologycapabilities-xml.js +64 -0
- package/tx/xml/valueset-xml.js +64 -0
- package/tx/xml/xml-base.js +603 -0
- package/vcl/vcl-parser.js +1098 -0
- package/vcl/vcl.js +253 -0
- package/windows-install.js +19 -0
- package/xig/xig-template.html +124 -0
- package/xig/xig.js +3049 -0
|
@@ -0,0 +1,2167 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const Database = require('sqlite3').Database;
|
|
5
|
+
const bcrypt = require('bcrypt');
|
|
6
|
+
const session = require('express-session');
|
|
7
|
+
const folders = require('../library/folder-setup');
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PublisherModule {
|
|
11
|
+
constructor(stats) {
|
|
12
|
+
this.router = express.Router();
|
|
13
|
+
this.db = null;
|
|
14
|
+
this.config = null;
|
|
15
|
+
this.logger = null;
|
|
16
|
+
this.taskProcessor = null;
|
|
17
|
+
this.isProcessing = false;
|
|
18
|
+
this.shutdownRequested = false;
|
|
19
|
+
this.stats = stats;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async initialize(config) {
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.logger = require('../library/logger').getInstance().child({ module: 'publisher' });
|
|
25
|
+
|
|
26
|
+
// Initialize database first
|
|
27
|
+
await this.initializeDatabase();
|
|
28
|
+
|
|
29
|
+
// Set up session middleware - use in-memory sessions or separate session db
|
|
30
|
+
this.router.use(session({
|
|
31
|
+
secret: this.config.sessionSecret || 'your-secret-key-change-this',
|
|
32
|
+
resave: false,
|
|
33
|
+
saveUninitialized: false,
|
|
34
|
+
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } // 24 hours
|
|
35
|
+
// Not using SQLiteStore to avoid the database conflict
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Parse form data
|
|
39
|
+
this.router.use(express.urlencoded({ extended: true }));
|
|
40
|
+
|
|
41
|
+
// Set up routes
|
|
42
|
+
this.setupRoutes();
|
|
43
|
+
|
|
44
|
+
// Start background task processor
|
|
45
|
+
this.startTaskProcessor();
|
|
46
|
+
|
|
47
|
+
this.logger.info('Publisher module initialized');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async initializeDatabase() {
|
|
51
|
+
// Ensure database directory exists
|
|
52
|
+
const dbPath = path.isAbsolute(this.config.database)
|
|
53
|
+
? this.config.database
|
|
54
|
+
: folders.filePath('publisher', this.config.database);
|
|
55
|
+
const dbDir = path.dirname(dbPath);
|
|
56
|
+
const fs = require('fs');
|
|
57
|
+
if (!fs.existsSync(dbDir)) {
|
|
58
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
59
|
+
this.logger.info('Created database directory: ' + dbDir);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
this.db = new Database(dbPath, (err) => {
|
|
64
|
+
if (err) {
|
|
65
|
+
this.logger.error('Failed to connect to database:', err);
|
|
66
|
+
reject(err);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.logger.info('Connected to SQLite database: ' + dbPath);
|
|
71
|
+
this.createTables().then(resolve).catch(reject);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async createTables() {
|
|
77
|
+
const tables = [
|
|
78
|
+
// Users table
|
|
79
|
+
`CREATE TABLE IF NOT EXISTS users (
|
|
80
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
81
|
+
name TEXT NOT NULL,
|
|
82
|
+
login TEXT UNIQUE NOT NULL,
|
|
83
|
+
password_hash TEXT NOT NULL,
|
|
84
|
+
is_admin BOOLEAN DEFAULT 0,
|
|
85
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
86
|
+
)`,
|
|
87
|
+
|
|
88
|
+
// Websites table
|
|
89
|
+
`CREATE TABLE IF NOT EXISTS websites (
|
|
90
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
91
|
+
name TEXT NOT NULL,
|
|
92
|
+
local_folder TEXT NOT NULL,
|
|
93
|
+
server_update_script TEXT NOT NULL,
|
|
94
|
+
is_active BOOLEAN DEFAULT 1,
|
|
95
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
96
|
+
)`,
|
|
97
|
+
|
|
98
|
+
// User website permissions
|
|
99
|
+
`CREATE TABLE IF NOT EXISTS user_website_permissions (
|
|
100
|
+
user_id INTEGER,
|
|
101
|
+
website_id INTEGER,
|
|
102
|
+
can_queue BOOLEAN DEFAULT 0,
|
|
103
|
+
can_approve BOOLEAN DEFAULT 0,
|
|
104
|
+
PRIMARY KEY (user_id, website_id),
|
|
105
|
+
FOREIGN KEY (user_id) REFERENCES users (id),
|
|
106
|
+
FOREIGN KEY (website_id) REFERENCES websites (id)
|
|
107
|
+
)`,
|
|
108
|
+
|
|
109
|
+
// Tasks table
|
|
110
|
+
`CREATE TABLE IF NOT EXISTS tasks (
|
|
111
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
112
|
+
user_id INTEGER NOT NULL,
|
|
113
|
+
website_id INTEGER NOT NULL,
|
|
114
|
+
status TEXT DEFAULT 'queued',
|
|
115
|
+
github_org TEXT NOT NULL,
|
|
116
|
+
github_repo TEXT NOT NULL,
|
|
117
|
+
git_branch TEXT NOT NULL,
|
|
118
|
+
npm_package_id TEXT NOT NULL,
|
|
119
|
+
version TEXT NOT NULL,
|
|
120
|
+
local_folder TEXT,
|
|
121
|
+
build_output_path TEXT,
|
|
122
|
+
failure_reason TEXT,
|
|
123
|
+
announcement TEXT,
|
|
124
|
+
approved_by INTEGER,
|
|
125
|
+
queued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
126
|
+
building_at DATETIME,
|
|
127
|
+
waiting_approval_at DATETIME,
|
|
128
|
+
publishing_at DATETIME,
|
|
129
|
+
completed_at DATETIME,
|
|
130
|
+
failed_at DATETIME,
|
|
131
|
+
FOREIGN KEY (user_id) REFERENCES users (id),
|
|
132
|
+
FOREIGN KEY (website_id) REFERENCES websites (id),
|
|
133
|
+
FOREIGN KEY (approved_by) REFERENCES users (id)
|
|
134
|
+
)`,
|
|
135
|
+
|
|
136
|
+
// Task logs
|
|
137
|
+
`CREATE TABLE IF NOT EXISTS task_logs (
|
|
138
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
139
|
+
task_id TEXT NOT NULL,
|
|
140
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
141
|
+
level TEXT NOT NULL,
|
|
142
|
+
message TEXT NOT NULL,
|
|
143
|
+
FOREIGN KEY (task_id) REFERENCES tasks (id)
|
|
144
|
+
)`,
|
|
145
|
+
|
|
146
|
+
// User actions audit
|
|
147
|
+
`CREATE TABLE IF NOT EXISTS user_actions (
|
|
148
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
149
|
+
user_id INTEGER,
|
|
150
|
+
action TEXT NOT NULL,
|
|
151
|
+
target_id TEXT,
|
|
152
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
153
|
+
ip_address TEXT,
|
|
154
|
+
FOREIGN KEY (user_id) REFERENCES users (id)
|
|
155
|
+
)`
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
for (const sql of tables) {
|
|
159
|
+
await new Promise((resolve, reject) => {
|
|
160
|
+
this.db.run(sql, (err) => {
|
|
161
|
+
if (err) {
|
|
162
|
+
this.logger.error('Failed to create table:', err);
|
|
163
|
+
reject(err);
|
|
164
|
+
} else {
|
|
165
|
+
resolve();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Create default admin user if none exists
|
|
172
|
+
await this.createDefaultAdmin();
|
|
173
|
+
|
|
174
|
+
// Schema migrations for existing databases
|
|
175
|
+
await this.runMigrations();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async runMigrations() {
|
|
179
|
+
// Add announcement column if it doesn't exist (added after initial schema)
|
|
180
|
+
const columns = await new Promise((resolve, reject) => {
|
|
181
|
+
this.db.all("PRAGMA table_info(tasks)", (err, rows) => {
|
|
182
|
+
if (err) reject(err);
|
|
183
|
+
else resolve(rows || []);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
const columnNames = columns.map(c => c.name);
|
|
187
|
+
if (!columnNames.includes('announcement')) {
|
|
188
|
+
await new Promise((resolve, reject) => {
|
|
189
|
+
this.db.run('ALTER TABLE tasks ADD COLUMN announcement TEXT', (err) => {
|
|
190
|
+
if (err) reject(err);
|
|
191
|
+
else {
|
|
192
|
+
this.logger.info('Migration: added announcement column to tasks table');
|
|
193
|
+
resolve();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async createDefaultAdmin() {
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
this.db.get('SELECT COUNT(*) as count FROM users WHERE is_admin = 1', (err, row) => {
|
|
203
|
+
if (err) {
|
|
204
|
+
reject(err);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (row.count === 0) {
|
|
209
|
+
const defaultPassword = 'admin123'; // Change this!
|
|
210
|
+
bcrypt.hash(defaultPassword, 10, (err, hash) => {
|
|
211
|
+
if (err) {
|
|
212
|
+
reject(err);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.db.run(
|
|
217
|
+
'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
|
|
218
|
+
['Administrator', 'admin', hash, 1],
|
|
219
|
+
(err) => {
|
|
220
|
+
if (err) {
|
|
221
|
+
this.logger.error('Failed to create default admin:', err);
|
|
222
|
+
reject(err);
|
|
223
|
+
} else {
|
|
224
|
+
this.logger.warn('Created default admin user - login: admin, password: admin123 - CHANGE THIS!');
|
|
225
|
+
resolve();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
} else {
|
|
231
|
+
resolve();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
setupRoutes() {
|
|
238
|
+
// Main dashboard
|
|
239
|
+
this.router.get('/', this.renderDashboard.bind(this));
|
|
240
|
+
|
|
241
|
+
// Authentication
|
|
242
|
+
this.router.get('/login', this.renderLogin.bind(this));
|
|
243
|
+
this.router.post('/login', this.handleLogin.bind(this));
|
|
244
|
+
this.router.post('/logout', this.handleLogout.bind(this));
|
|
245
|
+
|
|
246
|
+
// Tasks
|
|
247
|
+
this.router.get('/tasks', this.renderTasks.bind(this));
|
|
248
|
+
this.router.post('/tasks', this.requireAuth.bind(this), this.createTask.bind(this));
|
|
249
|
+
this.router.post('/tasks/:id/approve', this.requireAuth.bind(this), this.approveTask.bind(this));
|
|
250
|
+
this.router.post('/tasks/:id/delete', this.requireAdmin.bind(this), this.deleteTask.bind(this));
|
|
251
|
+
this.router.get('/tasks/:id/output', this.getTaskOutput.bind(this));
|
|
252
|
+
this.router.get('/tasks/:id/history', this.getTaskHistory.bind(this));
|
|
253
|
+
this.router.get('/tasks/:id/qa', this.getTaskQA.bind(this));
|
|
254
|
+
this.router.use('/tasks/:id/qa-files', (req, res, next) => {
|
|
255
|
+
const taskId = req.params.id;
|
|
256
|
+
this.getTask(taskId).then(task => {
|
|
257
|
+
if (!task || !task.local_folder) {
|
|
258
|
+
return res.status(404).send('Not found');
|
|
259
|
+
}
|
|
260
|
+
const outputDir = path.join(task.local_folder, 'draft', 'output');
|
|
261
|
+
express.static(outputDir)(req, res, next);
|
|
262
|
+
}).catch(() => res.status(500).send('Error'));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Admin routes
|
|
266
|
+
this.router.get('/admin/websites', this.requireAdmin.bind(this), this.renderWebsites.bind(this));
|
|
267
|
+
this.router.post('/admin/websites', this.requireAdmin.bind(this), this.createWebsite.bind(this));
|
|
268
|
+
this.router.get('/admin/users', this.requireAdmin.bind(this), this.renderUsers.bind(this));
|
|
269
|
+
this.router.post('/admin/users', this.requireAdmin.bind(this), this.createUser.bind(this));
|
|
270
|
+
this.router.post('/admin/permissions', this.requireAdmin.bind(this), this.updatePermissions.bind(this));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Background Task Processing
|
|
274
|
+
startTaskProcessor() {
|
|
275
|
+
const pollInterval = this.config.pollInterval || 5000; // Default 5 seconds
|
|
276
|
+
|
|
277
|
+
this.logger.info('Starting task processor with ' + pollInterval + 'ms poll interval');
|
|
278
|
+
|
|
279
|
+
this.taskProcessor = setInterval(async () => {
|
|
280
|
+
if (!this.isProcessing && !this.shutdownRequested) {
|
|
281
|
+
await this.processNextTask();
|
|
282
|
+
}
|
|
283
|
+
}, pollInterval);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async processNextTask() {
|
|
287
|
+
this.isProcessing = true;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
// Look for queued tasks first (draft builds)
|
|
291
|
+
let task = await this.getNextQueuedTask();
|
|
292
|
+
if (task) {
|
|
293
|
+
await this.processDraftBuild(task);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Then look for approved tasks (publishing)
|
|
298
|
+
task = await this.getNextApprovedTask();
|
|
299
|
+
if (task) {
|
|
300
|
+
await this.processPublication(task);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// No tasks to process
|
|
305
|
+
} catch (error) {
|
|
306
|
+
this.logger.error('Error in task processor:', error);
|
|
307
|
+
} finally {
|
|
308
|
+
this.isProcessing = false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async getNextQueuedTask() {
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
this.db.get(
|
|
315
|
+
'SELECT * FROM tasks WHERE status = ? ORDER BY queued_at ASC LIMIT 1',
|
|
316
|
+
['queued'],
|
|
317
|
+
(err, row) => {
|
|
318
|
+
if (err) reject(err);
|
|
319
|
+
else resolve(row);
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async getNextApprovedTask() {
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
this.db.get(
|
|
328
|
+
'SELECT * FROM tasks WHERE status = ? ORDER BY publishing_at ASC LIMIT 1',
|
|
329
|
+
['publishing'],
|
|
330
|
+
(err, row) => {
|
|
331
|
+
if (err) reject(err);
|
|
332
|
+
else resolve(row);
|
|
333
|
+
}
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async updateTaskStatus(taskId, status, additionalFields = {}) {
|
|
339
|
+
const fields = ['status = ?'];
|
|
340
|
+
const values = [status];
|
|
341
|
+
|
|
342
|
+
// Add timestamp field based on status
|
|
343
|
+
if (status === 'building') {
|
|
344
|
+
fields.push('building_at = CURRENT_TIMESTAMP');
|
|
345
|
+
} else if (status === 'waiting for approval') {
|
|
346
|
+
fields.push('waiting_approval_at = CURRENT_TIMESTAMP');
|
|
347
|
+
} else if (status === 'complete') {
|
|
348
|
+
fields.push('completed_at = CURRENT_TIMESTAMP');
|
|
349
|
+
} else if (status === 'failed') {
|
|
350
|
+
fields.push('failed_at = CURRENT_TIMESTAMP');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Add any additional fields
|
|
354
|
+
Object.keys(additionalFields).forEach(key => {
|
|
355
|
+
fields.push(key + ' = ?');
|
|
356
|
+
values.push(additionalFields[key]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
values.push(taskId);
|
|
360
|
+
|
|
361
|
+
return new Promise((resolve, reject) => {
|
|
362
|
+
this.db.run(
|
|
363
|
+
'UPDATE tasks SET ' + fields.join(', ') + ' WHERE id = ?',
|
|
364
|
+
values,
|
|
365
|
+
(err) => {
|
|
366
|
+
if (err) reject(err);
|
|
367
|
+
else resolve();
|
|
368
|
+
}
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async logTaskMessage(taskId, level, message) {
|
|
374
|
+
return new Promise((resolve) => {
|
|
375
|
+
this.db.run(
|
|
376
|
+
'INSERT INTO task_logs (task_id, level, message) VALUES (?, ?, ?)',
|
|
377
|
+
[taskId.toString(), level, message],
|
|
378
|
+
() => resolve() // Don't fail if logging fails
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async processDraftBuild(task) {
|
|
384
|
+
this.logger.info('Processing draft build for task #' + task.id + ' (' + task.npm_package_id + '#' + task.version + ')');
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
// Update status to building
|
|
388
|
+
await this.updateTaskStatus(task.id, 'building');
|
|
389
|
+
await this.logTaskMessage(task.id, 'info', 'Started draft build');
|
|
390
|
+
|
|
391
|
+
// Run actual build process
|
|
392
|
+
await this.runDraftBuild(task);
|
|
393
|
+
|
|
394
|
+
// Update status to waiting for approval
|
|
395
|
+
await this.updateTaskStatus(task.id, 'waiting for approval');
|
|
396
|
+
await this.logTaskMessage(task.id, 'info', 'Draft build completed - waiting for approval');
|
|
397
|
+
|
|
398
|
+
this.logger.info('Draft build completed for task #' + task.id);
|
|
399
|
+
|
|
400
|
+
} catch (error) {
|
|
401
|
+
this.logger.error('Draft build failed for task #' + task.id + ':', error);
|
|
402
|
+
await this.updateTaskStatus(task.id, 'failed', {
|
|
403
|
+
failure_reason: error.message
|
|
404
|
+
});
|
|
405
|
+
await this.logTaskMessage(task.id, 'error', 'Draft build failed: ' + error.message);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async processPublication(task) {
|
|
410
|
+
this.logger.info('Processing publication for task #' + task.id + ' (' + task.npm_package_id + '#' + task.version + ')');
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
await this.logTaskMessage(task.id, 'info', 'Started publication process');
|
|
414
|
+
|
|
415
|
+
await this.runPublication(task);
|
|
416
|
+
|
|
417
|
+
// Update status to complete
|
|
418
|
+
await this.updateTaskStatus(task.id, 'complete');
|
|
419
|
+
await this.logTaskMessage(task.id, 'info', 'Publication completed successfully');
|
|
420
|
+
|
|
421
|
+
this.logger.info('Publication completed for task #' + task.id);
|
|
422
|
+
|
|
423
|
+
} catch (error) {
|
|
424
|
+
this.logger.error('Publication failed for task #' + task.id + ':', error);
|
|
425
|
+
await this.updateTaskStatus(task.id, 'failed', {
|
|
426
|
+
failure_reason: error.message
|
|
427
|
+
});
|
|
428
|
+
await this.logTaskMessage(task.id, 'error', 'Publication failed: ' + error.message);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async runDraftBuild(task) {
|
|
433
|
+
const workspaceRoot = path.isAbsolute(this.config.workspaceRoot)
|
|
434
|
+
? this.config.workspaceRoot
|
|
435
|
+
: folders.filePath('publisher', this.config.workspaceRoot);
|
|
436
|
+
|
|
437
|
+
const taskDir = path.join(workspaceRoot, 'task-' + task.id);
|
|
438
|
+
const draftDir = path.join(taskDir, 'draft');
|
|
439
|
+
const logFile = path.join(taskDir, 'draft-build.log');
|
|
440
|
+
|
|
441
|
+
await this.logTaskMessage(task.id, 'info', 'Creating task directory: ' + taskDir);
|
|
442
|
+
|
|
443
|
+
// Step 1: Create/scrub task directory
|
|
444
|
+
await this.createTaskDirectory(taskDir);
|
|
445
|
+
|
|
446
|
+
// Step 2: Download latest publisher
|
|
447
|
+
const publisherJar = await this.downloadPublisher(taskDir, task.id);
|
|
448
|
+
|
|
449
|
+
// Step 3: Clone GitHub repository
|
|
450
|
+
await this.cloneRepository(task, draftDir);
|
|
451
|
+
|
|
452
|
+
// Step 4: Run IG publisher
|
|
453
|
+
await this.runIGPublisher(publisherJar, draftDir, logFile, task.id);
|
|
454
|
+
|
|
455
|
+
// Step 5: Verify package-id and version match the task
|
|
456
|
+
await this.verifyBuildOutput(task, draftDir);
|
|
457
|
+
|
|
458
|
+
// Update task with build output path
|
|
459
|
+
await this.updateTaskStatus(task.id, task.status, {
|
|
460
|
+
build_output_path: logFile,
|
|
461
|
+
local_folder: taskDir
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
this.logger.info('Draft build completed for ' + task.npm_package_id + '#' + task.version);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async createTaskDirectory(taskDir) {
|
|
468
|
+
const rimraf = require('rimraf');
|
|
469
|
+
|
|
470
|
+
// Remove existing directory if it exists
|
|
471
|
+
if (fs.existsSync(taskDir)) {
|
|
472
|
+
await new Promise((resolve, reject) => {
|
|
473
|
+
rimraf(taskDir, (err) => {
|
|
474
|
+
if (err) reject(err);
|
|
475
|
+
else resolve();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Create fresh directory
|
|
481
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async downloadPublisher(taskDir, taskId) {
|
|
485
|
+
const axios = require('axios');
|
|
486
|
+
const publisherJar = path.join(taskDir, 'publisher.jar');
|
|
487
|
+
|
|
488
|
+
await this.logTaskMessage(taskId, 'info', 'Downloading latest FHIR IG Publisher...');
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
// Get latest release info from GitHub API
|
|
492
|
+
const releaseResponse = await axios.get('https://api.github.com/repos/HL7/fhir-ig-publisher/releases/latest');
|
|
493
|
+
const downloadUrl = releaseResponse.data.assets.find(asset =>
|
|
494
|
+
asset.name === 'publisher.jar'
|
|
495
|
+
)?.browser_download_url;
|
|
496
|
+
|
|
497
|
+
if (!downloadUrl) {
|
|
498
|
+
throw new Error('Could not find publisher.jar in latest release');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
await this.logTaskMessage(taskId, 'info', 'Downloading from: ' + downloadUrl);
|
|
502
|
+
|
|
503
|
+
// Download the file
|
|
504
|
+
const response = await axios({
|
|
505
|
+
method: 'GET',
|
|
506
|
+
url: downloadUrl,
|
|
507
|
+
responseType: 'stream'
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const writer = fs.createWriteStream(publisherJar);
|
|
511
|
+
response.data.pipe(writer);
|
|
512
|
+
|
|
513
|
+
await new Promise((resolve, reject) => {
|
|
514
|
+
writer.on('finish', resolve);
|
|
515
|
+
writer.on('error', reject);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await this.logTaskMessage(taskId, 'info', 'Publisher downloaded successfully');
|
|
519
|
+
return publisherJar;
|
|
520
|
+
|
|
521
|
+
} catch (error) {
|
|
522
|
+
throw new Error('Failed to download publisher: ' + error.message);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async cloneRepository(task, draftDir) {
|
|
527
|
+
const { spawn } = require('child_process');
|
|
528
|
+
const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
|
|
529
|
+
|
|
530
|
+
await this.logTaskMessage(task.id, 'info', 'Cloning repository: ' + gitUrl + ' (branch: ' + task.git_branch + ')');
|
|
531
|
+
|
|
532
|
+
return new Promise((resolve, reject) => {
|
|
533
|
+
const git = spawn('git', [
|
|
534
|
+
'clone',
|
|
535
|
+
'--branch', task.git_branch,
|
|
536
|
+
'--single-branch',
|
|
537
|
+
gitUrl,
|
|
538
|
+
draftDir
|
|
539
|
+
], {
|
|
540
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
let stderr = '';
|
|
544
|
+
|
|
545
|
+
git.stderr.on('data', (data) => {
|
|
546
|
+
stderr += data.toString();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
git.on('close', async (code) => {
|
|
550
|
+
if (code === 0) {
|
|
551
|
+
await this.logTaskMessage(task.id, 'info', 'Repository cloned successfully');
|
|
552
|
+
resolve();
|
|
553
|
+
} else {
|
|
554
|
+
const error = 'Git clone failed with code ' + code + ': ' + stderr;
|
|
555
|
+
await this.logTaskMessage(task.id, 'error', error);
|
|
556
|
+
reject(new Error(error));
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
git.on('error', async (error) => {
|
|
561
|
+
await this.logTaskMessage(task.id, 'error', 'Git clone error: ' + error.message);
|
|
562
|
+
reject(error);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async runIGPublisher(publisherJar, draftDir, logFile, taskId) {
|
|
568
|
+
const { spawn } = require('child_process');
|
|
569
|
+
|
|
570
|
+
await this.logTaskMessage(taskId, 'info', 'Running FHIR IG Publisher...');
|
|
571
|
+
|
|
572
|
+
return new Promise((resolve, reject) => {
|
|
573
|
+
const java = spawn('java', [
|
|
574
|
+
'-jar',
|
|
575
|
+
'-Xmx20000m',
|
|
576
|
+
publisherJar,
|
|
577
|
+
'-ig',
|
|
578
|
+
'.'
|
|
579
|
+
], {
|
|
580
|
+
cwd: draftDir,
|
|
581
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// Create log file stream
|
|
585
|
+
const logStream = fs.createWriteStream(logFile);
|
|
586
|
+
|
|
587
|
+
java.stdout.on('data', (data) => {
|
|
588
|
+
logStream.write(data);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
java.stderr.on('data', (data) => {
|
|
592
|
+
logStream.write(data);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
java.on('close', async (code) => {
|
|
596
|
+
logStream.end();
|
|
597
|
+
|
|
598
|
+
if (code === 0) {
|
|
599
|
+
await this.logTaskMessage(taskId, 'info', 'IG Publisher completed successfully');
|
|
600
|
+
resolve();
|
|
601
|
+
} else {
|
|
602
|
+
const error = 'IG Publisher failed with exit code: ' + code;
|
|
603
|
+
await this.logTaskMessage(taskId, 'error', error);
|
|
604
|
+
reject(new Error(error));
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
java.on('error', async (error) => {
|
|
609
|
+
logStream.end();
|
|
610
|
+
await this.logTaskMessage(taskId, 'error', 'IG Publisher error: ' + error.message);
|
|
611
|
+
reject(error);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// Timeout after 30 minutes
|
|
615
|
+
const timeout = setTimeout(async () => {
|
|
616
|
+
java.kill();
|
|
617
|
+
logStream.end();
|
|
618
|
+
await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after 30 minutes');
|
|
619
|
+
reject(new Error('IG Publisher timed out'));
|
|
620
|
+
}, 30 * 60 * 1000);
|
|
621
|
+
|
|
622
|
+
java.on('close', () => {
|
|
623
|
+
clearTimeout(timeout);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async verifyBuildOutput(task, draftDir) {
|
|
629
|
+
const qaJsonPath = path.join(draftDir, 'output', 'qa.json');
|
|
630
|
+
|
|
631
|
+
await this.logTaskMessage(task.id, 'info', 'Verifying build output against task parameters...');
|
|
632
|
+
|
|
633
|
+
if (!fs.existsSync(qaJsonPath)) {
|
|
634
|
+
throw new Error('Build verification failed: qa.json not found in output directory');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const qaData = JSON.parse(fs.readFileSync(qaJsonPath, 'utf8'));
|
|
638
|
+
|
|
639
|
+
const errors = [];
|
|
640
|
+
if (qaData['package-id'] !== task.npm_package_id) {
|
|
641
|
+
errors.push('Package ID mismatch: task specifies "' + task.npm_package_id + '" but build produced "' + qaData['package-id'] + '"');
|
|
642
|
+
}
|
|
643
|
+
if (qaData['ig-ver'] !== task.version) {
|
|
644
|
+
errors.push('Version mismatch: task specifies "' + task.version + '" but build produced "' + qaData['ig-ver'] + '"');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (errors.length > 0) {
|
|
648
|
+
for (const err of errors) {
|
|
649
|
+
await this.logTaskMessage(task.id, 'error', err);
|
|
650
|
+
}
|
|
651
|
+
throw new Error('Build verification failed: ' + errors.join('; '));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
await this.logTaskMessage(task.id, 'info', 'Build output verified: package-id=' + qaData['package-id'] + ', version=' + qaData['ig-ver']);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async runPublication(task) {
|
|
658
|
+
const website = await this.getWebsite(task.website_id);
|
|
659
|
+
if (!website) {
|
|
660
|
+
throw new Error('Website not found for task');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (!task.local_folder) {
|
|
664
|
+
throw new Error('Task has no local folder - draft build may not have completed');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const taskDir = task.local_folder;
|
|
668
|
+
const draftDir = path.join(taskDir, 'draft');
|
|
669
|
+
const publishLogFile = path.join(taskDir, 'publication.log');
|
|
670
|
+
|
|
671
|
+
// Ensure the zips directory exists
|
|
672
|
+
const zipsDir = folders.filePath('publisher', 'zips');
|
|
673
|
+
if (!fs.existsSync(zipsDir)) {
|
|
674
|
+
fs.mkdirSync(zipsDir, { recursive: true });
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Step 1: Clone supporting repositories into the task directory
|
|
678
|
+
const registryDir = path.join(taskDir, 'ig-registry');
|
|
679
|
+
const historyDir = path.join(taskDir, 'fhir-ig-history-template');
|
|
680
|
+
const templatesDir = path.join(taskDir, 'fhir-web-templates');
|
|
681
|
+
|
|
682
|
+
await this.runCommand('git', ['clone', 'https://github.com/FHIR/ig-registry.git', registryDir],
|
|
683
|
+
{}, task.id, 'Cloning ig-registry');
|
|
684
|
+
|
|
685
|
+
await this.runCommand('git', ['clone', 'https://github.com/HL7/fhir-ig-history-template.git', historyDir],
|
|
686
|
+
{}, task.id, 'Cloning fhir-ig-history-template');
|
|
687
|
+
|
|
688
|
+
await this.runCommand('git', ['clone', 'https://github.com/HL7/fhir-web-templates.git', templatesDir],
|
|
689
|
+
{}, task.id, 'Cloning fhir-web-templates');
|
|
690
|
+
|
|
691
|
+
// Step 2: Reuse the publisher.jar from the draft build
|
|
692
|
+
const publisherJar = path.join(taskDir, 'publisher.jar');
|
|
693
|
+
if (!fs.existsSync(publisherJar)) {
|
|
694
|
+
throw new Error('publisher.jar not found in task directory - draft build may be corrupt');
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Step 3: Pull latest web folder before publishing into it
|
|
698
|
+
await this.runCommand('git', ['pull'], { cwd: website.local_folder }, task.id, 'Pulling latest web folder');
|
|
699
|
+
|
|
700
|
+
// Step 4: Run the IG publisher in go-publish mode
|
|
701
|
+
await this.runPublisherGoPublish(task.id, publisherJar, draftDir, website.local_folder,
|
|
702
|
+
registryDir, historyDir, templatesDir, zipsDir, publishLogFile);
|
|
703
|
+
|
|
704
|
+
// Step 5: Verify publication succeeded by checking for the log file
|
|
705
|
+
const pubLogName = task.npm_package_id + '#' + task.version + '.log';
|
|
706
|
+
const pubLogPath = path.join(zipsDir, pubLogName);
|
|
707
|
+
if (!fs.existsSync(pubLogPath)) {
|
|
708
|
+
throw new Error('Publication verification failed: ' + pubLogName + ' not found in zips directory');
|
|
709
|
+
}
|
|
710
|
+
await this.logTaskMessage(task.id, 'info', 'Publication run verified: ' + pubLogName + ' found');
|
|
711
|
+
|
|
712
|
+
// Step 6: Commit and push the web folder
|
|
713
|
+
await this.logTaskMessage(task.id, 'info', 'Committing changes to web folder...');
|
|
714
|
+
const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
|
|
715
|
+
const commitMsg = 'publish ' + task.npm_package_id + '#' + task.version + ' from ' + gitUrl + ' ' + task.git_branch;
|
|
716
|
+
await this.runCommand('git', ['add', '.'], { cwd: website.local_folder }, task.id, 'Staging web folder changes');
|
|
717
|
+
await this.runCommand('git', ['commit', '-m', commitMsg], { cwd: website.local_folder }, task.id, 'Committing web folder changes');
|
|
718
|
+
await this.runCommand('git', ['push'], { cwd: website.local_folder }, task.id, 'Pushing web folder changes');
|
|
719
|
+
|
|
720
|
+
// Step 7: Commit and push the ig-registry
|
|
721
|
+
await this.logTaskMessage(task.id, 'info', 'Committing changes to ig-registry...');
|
|
722
|
+
const registryCommitMsg = 'publish ' + task.npm_package_id + '#' + task.version;
|
|
723
|
+
await this.runCommand('git', ['commit', '-a', '-m', registryCommitMsg], { cwd: registryDir }, task.id, 'Committing ig-registry changes');
|
|
724
|
+
await this.runCommand('git', ['pull'], { cwd: registryDir }, task.id, 'Pulling latest ig-registry');
|
|
725
|
+
await this.runCommand('git', ['push'], { cwd: registryDir }, task.id, 'Pushing ig-registry changes');
|
|
726
|
+
|
|
727
|
+
// Step 8: Read the announcement text and store it in the database
|
|
728
|
+
const announcementPath = path.join(zipsDir, task.npm_package_id + '#' + task.version + '-announcement.txt');
|
|
729
|
+
if (fs.existsSync(announcementPath)) {
|
|
730
|
+
try {
|
|
731
|
+
const announcement = fs.readFileSync(announcementPath, 'utf8');
|
|
732
|
+
await this.updateTaskStatus(task.id, task.status, { announcement: announcement });
|
|
733
|
+
await this.logTaskMessage(task.id, 'info', 'Announcement text saved (' + announcement.length + ' chars)');
|
|
734
|
+
} catch (err) {
|
|
735
|
+
await this.logTaskMessage(task.id, 'warn', 'Failed to read announcement file: ' + err.message);
|
|
736
|
+
}
|
|
737
|
+
} else {
|
|
738
|
+
await this.logTaskMessage(task.id, 'warn', 'No announcement file found at ' + announcementPath);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Step 9: Run the website update script
|
|
742
|
+
if (website.server_update_script) {
|
|
743
|
+
await this.logTaskMessage(task.id, 'info', 'Running website update script: ' + website.server_update_script);
|
|
744
|
+
await this.runCommand('bash', ['-c', website.server_update_script], {}, task.id, 'Running website update script');
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async runPublisherGoPublish(taskId, publisherJar, sourceDir, webDir, registryDir, historyDir, templatesDir, zipsDir, logFile) {
|
|
749
|
+
const { spawn } = require('child_process');
|
|
750
|
+
|
|
751
|
+
const registryFile = path.join(registryDir, 'fhir-ig-list.json');
|
|
752
|
+
|
|
753
|
+
const args = [
|
|
754
|
+
'-jar', '-Xmx20000m', publisherJar,
|
|
755
|
+
'-go-publish',
|
|
756
|
+
'-source', sourceDir,
|
|
757
|
+
'-web', webDir,
|
|
758
|
+
'-registry', registryFile,
|
|
759
|
+
'-history', historyDir,
|
|
760
|
+
'-templates', templatesDir,
|
|
761
|
+
'-zips', zipsDir
|
|
762
|
+
];
|
|
763
|
+
|
|
764
|
+
await this.logTaskMessage(taskId, 'info', 'java ' + args.join(' '));
|
|
765
|
+
|
|
766
|
+
return new Promise((resolve, reject) => {
|
|
767
|
+
const java = spawn('java', args, {
|
|
768
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const logStream = fs.createWriteStream(logFile);
|
|
772
|
+
|
|
773
|
+
java.stdout.on('data', (data) => {
|
|
774
|
+
logStream.write(data);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
java.stderr.on('data', (data) => {
|
|
778
|
+
logStream.write(data);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
java.on('close', async (code) => {
|
|
782
|
+
logStream.end();
|
|
783
|
+
if (code === 0) {
|
|
784
|
+
await this.logTaskMessage(taskId, 'info', 'IG Publisher go-publish completed successfully');
|
|
785
|
+
resolve();
|
|
786
|
+
} else {
|
|
787
|
+
const error = 'IG Publisher go-publish failed with exit code: ' + code;
|
|
788
|
+
await this.logTaskMessage(taskId, 'error', error);
|
|
789
|
+
reject(new Error(error));
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
java.on('error', async (error) => {
|
|
794
|
+
logStream.end();
|
|
795
|
+
await this.logTaskMessage(taskId, 'error', 'IG Publisher error: ' + error.message);
|
|
796
|
+
reject(error);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// Timeout after 60 minutes for publication (longer than draft build)
|
|
800
|
+
const timeout = setTimeout(async () => {
|
|
801
|
+
java.kill();
|
|
802
|
+
logStream.end();
|
|
803
|
+
await this.logTaskMessage(taskId, 'error', 'IG Publisher go-publish timed out after 60 minutes');
|
|
804
|
+
reject(new Error('IG Publisher go-publish timed out'));
|
|
805
|
+
}, 60 * 60 * 1000);
|
|
806
|
+
|
|
807
|
+
java.on('close', () => {
|
|
808
|
+
clearTimeout(timeout);
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
stopTaskProcessor() {
|
|
814
|
+
if (this.taskProcessor) {
|
|
815
|
+
clearInterval(this.taskProcessor);
|
|
816
|
+
this.taskProcessor = null;
|
|
817
|
+
this.logger.info('Task processor stopped');
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Middleware
|
|
822
|
+
requireAuth(req, res, next) {
|
|
823
|
+
if (!req.session.userId) {
|
|
824
|
+
return res.redirect('/publisher/login');
|
|
825
|
+
}
|
|
826
|
+
next();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
requireAdmin(req, res, next) {
|
|
830
|
+
if (!req.session.userId || !req.session.isAdmin) {
|
|
831
|
+
return res.status(403).send('Admin access required');
|
|
832
|
+
}
|
|
833
|
+
next();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Route handlers
|
|
837
|
+
async renderDashboard(req, res) {
|
|
838
|
+
const start = Date.now();
|
|
839
|
+
try {
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
const htmlServer = require('../library/html-server');
|
|
843
|
+
|
|
844
|
+
// Get recent tasks
|
|
845
|
+
const tasks = await this.getTasks(10);
|
|
846
|
+
|
|
847
|
+
let content = '<div class="row mb-4">';
|
|
848
|
+
content += '<div class="col-12">';
|
|
849
|
+
|
|
850
|
+
if (req.session.userId) {
|
|
851
|
+
content += '<p>Welcome, ' + req.session.userName + '!</p>';
|
|
852
|
+
content += '<div class="mb-3">';
|
|
853
|
+
content += '<a href="/publisher/tasks" class="btn btn-primary me-2">View All Tasks</a>';
|
|
854
|
+
if (req.session.isAdmin) {
|
|
855
|
+
content += '<a href="/publisher/admin/websites" class="btn btn-secondary me-2">Manage Websites</a>';
|
|
856
|
+
content += '<a href="/publisher/admin/users" class="btn btn-secondary">Manage Users</a>';
|
|
857
|
+
}
|
|
858
|
+
content += '<form style="display: inline-block; margin-left: 10px;" method="post" action="/publisher/logout">';
|
|
859
|
+
content += '<button type="submit" class="btn btn-outline-secondary">Logout</button>';
|
|
860
|
+
content += '</form>';
|
|
861
|
+
content += '</div>';
|
|
862
|
+
} else {
|
|
863
|
+
content += '<p><a href="/publisher/login" class="btn btn-primary">Login</a> to create and manage publication tasks.</p>';
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Recent tasks
|
|
867
|
+
content += '<h3>Recent Tasks</h3>';
|
|
868
|
+
if (tasks.length === 0) {
|
|
869
|
+
content += '<p>No tasks found.</p>';
|
|
870
|
+
} else {
|
|
871
|
+
content += '<div class="table-responsive">';
|
|
872
|
+
content += '<table class="table table-striped">';
|
|
873
|
+
content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Status</th><th>Queued</th><th>User</th></tr></thead>';
|
|
874
|
+
content += '<tbody>';
|
|
875
|
+
|
|
876
|
+
tasks.forEach(task => {
|
|
877
|
+
content += '<tr>';
|
|
878
|
+
content += '<td><strong>#' + task.id + '</strong></td>';
|
|
879
|
+
content += '<td>' + task.npm_package_id + '</td>';
|
|
880
|
+
content += '<td>' + task.version + '</td>';
|
|
881
|
+
content += '<td><span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></td>';
|
|
882
|
+
content += '<td>' + new Date(task.queued_at).toLocaleString() + '</td>';
|
|
883
|
+
content += '<td>' + task.user_name + '</td>';
|
|
884
|
+
content += '</tr>';
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
content += '</tbody></table>';
|
|
888
|
+
content += '</div>';
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
content += '</div>';
|
|
892
|
+
content += '</div>';
|
|
893
|
+
|
|
894
|
+
const html = htmlServer.renderPage('publisher', 'FHIR Publisher', content, {
|
|
895
|
+
taskCount: tasks.length,
|
|
896
|
+
templateVars: {
|
|
897
|
+
loginTitle: req.session.userId ? "Logout" : 'Login',
|
|
898
|
+
loginPath: req.session.userId ? "logout" : 'login',
|
|
899
|
+
loginAction: req.session.userId ? "POST" : 'GET'
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
res.setHeader('Content-Type', 'text/html');
|
|
904
|
+
res.send(html);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
this.logger.error('Error rendering dashboard:', error);
|
|
907
|
+
res.status(500).send('Internal server error');
|
|
908
|
+
}
|
|
909
|
+
} finally {
|
|
910
|
+
this.stats.countRequest('dashboard', Date.now() - start);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
renderLogin(req, res) {
|
|
915
|
+
const start = Date.now();
|
|
916
|
+
try {
|
|
917
|
+
|
|
918
|
+
const htmlServer = require('../library/html-server');
|
|
919
|
+
|
|
920
|
+
let content = '<div class="row justify-content-center">';
|
|
921
|
+
content += '<div class="col-md-6">';
|
|
922
|
+
content += '<h3>Login</h3>';
|
|
923
|
+
content += '<form method="post" action="/publisher/login">';
|
|
924
|
+
content += '<div class="mb-3">';
|
|
925
|
+
content += '<label for="login" class="form-label">Username</label>';
|
|
926
|
+
content += '<input type="text" class="form-control" id="login" name="login" required>';
|
|
927
|
+
content += '</div>';
|
|
928
|
+
content += '<div class="mb-3">';
|
|
929
|
+
content += '<label for="password" class="form-label">Password</label>';
|
|
930
|
+
content += '<input type="password" class="form-control" id="password" name="password" required>';
|
|
931
|
+
content += '</div>';
|
|
932
|
+
content += '<button type="submit" class="btn btn-primary">Login</button>';
|
|
933
|
+
content += '</form>';
|
|
934
|
+
content += '</div>';
|
|
935
|
+
content += '</div>';
|
|
936
|
+
|
|
937
|
+
const html = htmlServer.renderPage('publisher', 'Login - FHIR Publisher', content, {
|
|
938
|
+
templateVars: {
|
|
939
|
+
loginTitle: req.session.userId ? "Logout" : 'Login',
|
|
940
|
+
loginPath: req.session.userId ? "logout" : 'login',
|
|
941
|
+
loginAction: req.session.userId ? "POST" : 'GET'
|
|
942
|
+
}});
|
|
943
|
+
res.setHeader('Content-Type', 'text/html');
|
|
944
|
+
res.send(html);
|
|
945
|
+
} finally {
|
|
946
|
+
this.stats.countRequest('login', Date.now() - start);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async handleLogin(req, res) {
|
|
951
|
+
const start = Date.now();
|
|
952
|
+
try {
|
|
953
|
+
|
|
954
|
+
try {
|
|
955
|
+
const {login, password} = req.body;
|
|
956
|
+
|
|
957
|
+
const user = await new Promise((resolve, reject) => {
|
|
958
|
+
this.db.get(
|
|
959
|
+
'SELECT * FROM users WHERE login = ?',
|
|
960
|
+
[login],
|
|
961
|
+
(err, row) => {
|
|
962
|
+
if (err) reject(err);
|
|
963
|
+
else resolve(row);
|
|
964
|
+
}
|
|
965
|
+
);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
if (!user) {
|
|
969
|
+
return res.redirect('/publisher/login?error=invalid');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const passwordMatch = await bcrypt.compare(password, user.password_hash);
|
|
973
|
+
if (!passwordMatch) {
|
|
974
|
+
return res.redirect('/publisher/login?error=invalid');
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
req.session.userId = user.id;
|
|
978
|
+
req.session.userName = user.name;
|
|
979
|
+
req.session.isAdmin = user.is_admin;
|
|
980
|
+
|
|
981
|
+
// Log the action
|
|
982
|
+
this.logUserAction(user.id, 'login', null, req.ip);
|
|
983
|
+
|
|
984
|
+
res.redirect('/publisher');
|
|
985
|
+
} catch (error) {
|
|
986
|
+
this.logger.error('Login error:', error);
|
|
987
|
+
res.redirect('/publisher/login?error=server');
|
|
988
|
+
}
|
|
989
|
+
} finally {
|
|
990
|
+
this.stats.countRequest('login', Date.now() - start);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
handleLogout(req, res) {
|
|
995
|
+
const start = Date.now();
|
|
996
|
+
try {
|
|
997
|
+
|
|
998
|
+
req.session.destroy();
|
|
999
|
+
res.redirect('/publisher');
|
|
1000
|
+
} finally {
|
|
1001
|
+
this.stats.countRequest('logout', Date.now() - start);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async renderTasks(req, res) {
|
|
1006
|
+
const start = Date.now();
|
|
1007
|
+
try {
|
|
1008
|
+
|
|
1009
|
+
try {
|
|
1010
|
+
const htmlServer = require('../library/html-server');
|
|
1011
|
+
const tasks = await this.getTasks();
|
|
1012
|
+
const userWebsites = req.session.userId ? await this.getUserWebsites(req.session.userId) : [];
|
|
1013
|
+
|
|
1014
|
+
let content = '<div class="row mb-4">';
|
|
1015
|
+
|
|
1016
|
+
// Create task form for logged in users
|
|
1017
|
+
if (req.session.userId && userWebsites.length > 0) {
|
|
1018
|
+
content += '<div class="col-12 mb-4">';
|
|
1019
|
+
content += '<button class="btn btn-primary" onclick="document.getElementById(\'create-task-panel\').style.display = document.getElementById(\'create-task-panel\').style.display === \'none\' ? \'block\' : \'none\'">New Publication Task</button>';
|
|
1020
|
+
content += '<div id="create-task-panel" style="display: none;" class="mt-3">';
|
|
1021
|
+
content += '<h3>Create New Publication Task</h3>';
|
|
1022
|
+
content += '<form id="create-task-form" method="post" action="/publisher/tasks" class="row g-3">';
|
|
1023
|
+
content += '<div class="col-md-3">';
|
|
1024
|
+
content += '<label for="website_id" class="form-label">Target Website</label>';
|
|
1025
|
+
content += '<select class="form-select" id="website_id" name="website_id" required>';
|
|
1026
|
+
userWebsites.forEach(website => {
|
|
1027
|
+
content += '<option value="' + website.id + '">' + website.name + '</option>';
|
|
1028
|
+
});
|
|
1029
|
+
content += '</select>';
|
|
1030
|
+
content += '</div>';
|
|
1031
|
+
content += '<div class="col-md-3">';
|
|
1032
|
+
content += '<label for="github_org" class="form-label">GitHub Org</label>';
|
|
1033
|
+
content += '<input type="text" class="form-control" id="github_org" name="github_org" required placeholder="hl7">';
|
|
1034
|
+
content += '</div>';
|
|
1035
|
+
content += '<div class="col-md-3">';
|
|
1036
|
+
content += '<label for="github_repo" class="form-label">GitHub Repo</label>';
|
|
1037
|
+
content += '<input type="text" class="form-control" id="github_repo" name="github_repo" required placeholder="fhir-us-core">';
|
|
1038
|
+
content += '</div>';
|
|
1039
|
+
content += '<div class="col-md-3">';
|
|
1040
|
+
content += '<label for="git_branch" class="form-label">Branch</label>';
|
|
1041
|
+
content += '<input type="text" class="form-control" id="git_branch" name="git_branch" required placeholder="main">';
|
|
1042
|
+
content += '</div>';
|
|
1043
|
+
content += '<div class="col-md-4">';
|
|
1044
|
+
content += '<label for="npm_package_id" class="form-label">NPM Package ID</label>';
|
|
1045
|
+
content += '<input type="text" class="form-control" id="npm_package_id" name="npm_package_id" required placeholder="hl7.fhir.us.core">';
|
|
1046
|
+
content += '</div>';
|
|
1047
|
+
content += '<div class="col-md-4">';
|
|
1048
|
+
content += '<label for="version" class="form-label">Version</label>';
|
|
1049
|
+
content += '<input type="text" class="form-control" id="version" name="version" required placeholder="6.0.0">';
|
|
1050
|
+
content += '</div>';
|
|
1051
|
+
content += '<div class="col-md-4 d-flex align-items-end">';
|
|
1052
|
+
content += '<button type="submit" class="btn btn-primary">Create Task</button>';
|
|
1053
|
+
content += '</div>';
|
|
1054
|
+
content += '</form>';
|
|
1055
|
+
content += '</div>'; // create-task-panel
|
|
1056
|
+
content += '</div>';
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Tasks list
|
|
1060
|
+
content += '<div class="col-12">';
|
|
1061
|
+
content += '<h3>Publication Tasks</h3>';
|
|
1062
|
+
|
|
1063
|
+
if (tasks.length === 0) {
|
|
1064
|
+
content += '<p>No tasks found.</p>';
|
|
1065
|
+
} else {
|
|
1066
|
+
content += '<div class="table-responsive">';
|
|
1067
|
+
content += '<table class="table table-striped">';
|
|
1068
|
+
content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
|
|
1069
|
+
content += '<tbody>';
|
|
1070
|
+
|
|
1071
|
+
for (const task of tasks) {
|
|
1072
|
+
const canApprove = req.session.userId && task.status === 'waiting for approval' &&
|
|
1073
|
+
await this.userCanApprove(req.session.userId, task.website_id);
|
|
1074
|
+
|
|
1075
|
+
content += '<tr>';
|
|
1076
|
+
content += '<td><strong>#' + task.id + '</strong></td>';
|
|
1077
|
+
content += '<td><code>' + task.npm_package_id + '</code></td>';
|
|
1078
|
+
content += '<td>' + task.version + '</td>';
|
|
1079
|
+
content += '<td>' + task.website_name + '</td>';
|
|
1080
|
+
content += '<td><span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></td>';
|
|
1081
|
+
content += '<td>' + new Date(task.queued_at).toLocaleString() + '</td>';
|
|
1082
|
+
content += '<td>' + task.user_name + '</td>';
|
|
1083
|
+
content += '<td class="task-actions">';
|
|
1084
|
+
content += '<a href="/publisher/tasks/' + task.id + '/history" class="btn btn-sm btn-outline-secondary me-1">History</a>';
|
|
1085
|
+
|
|
1086
|
+
if (task.status === 'waiting for approval') {
|
|
1087
|
+
content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Output</a>';
|
|
1088
|
+
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-sm btn-outline-secondary me-1">View QA</a>';
|
|
1089
|
+
if (canApprove) {
|
|
1090
|
+
content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">';
|
|
1091
|
+
content += '<button type="submit" name="approve" class="btn btn-sm btn-success me-1">Approve</button>';
|
|
1092
|
+
content += '</form>';
|
|
1093
|
+
}
|
|
1094
|
+
} else {
|
|
1095
|
+
if (task.build_output_path) {
|
|
1096
|
+
content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-sm btn-outline-info me-1">View Output</a>';
|
|
1097
|
+
}
|
|
1098
|
+
if (task.failure_reason) {
|
|
1099
|
+
content += '<span class="text-danger small me-1">' + this.escapeHtml(task.failure_reason) + '</span>';
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (req.session.isAdmin && (task.status === 'waiting for approval' || task.status === 'failed')) {
|
|
1104
|
+
content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">';
|
|
1105
|
+
content += '<button type="submit" class="btn btn-sm btn-danger">Delete</button>';
|
|
1106
|
+
content += '</form>';
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
content += '</td>';
|
|
1110
|
+
content += '</tr>';
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
content += '</tbody></table>';
|
|
1114
|
+
content += '</div>';
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
content += '</div>';
|
|
1118
|
+
content += '</div>';
|
|
1119
|
+
|
|
1120
|
+
const html = htmlServer.renderPage('publisher', 'Tasks - FHIR Publisher', content, {
|
|
1121
|
+
templateVars: {
|
|
1122
|
+
loginTitle: req.session.userId ? "Logout" : 'Login',
|
|
1123
|
+
loginPath: req.session.userId ? "logout" : 'login',
|
|
1124
|
+
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1125
|
+
}});
|
|
1126
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1127
|
+
res.send(html);
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
this.logger.error('Error rendering tasks:', error);
|
|
1130
|
+
res.status(500).send('Internal server error');
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
} finally {
|
|
1134
|
+
this.stats.countRequest('tasks', Date.now() - start);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async createTask(req, res) {
|
|
1139
|
+
const start = Date.now();
|
|
1140
|
+
try {
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
try {
|
|
1144
|
+
const {website_id, github_org, github_repo, git_branch, npm_package_id, version} = req.body;
|
|
1145
|
+
|
|
1146
|
+
// Verify user has permission for this website
|
|
1147
|
+
const canQueue = await this.userCanQueue(req.session.userId, website_id);
|
|
1148
|
+
if (!canQueue) {
|
|
1149
|
+
return res.status(403).send('You do not have permission to create tasks for this website');
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Check for duplicate active tasks (only block if there's an active task for this package/version)
|
|
1153
|
+
const existingTask = await this.findActiveTask(npm_package_id, version);
|
|
1154
|
+
if (existingTask) {
|
|
1155
|
+
return res.status(400).send('An active task for this package and version is already in progress. Wait for it to complete or fail before resubmitting.');
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Insert task (ID will be auto-generated)
|
|
1159
|
+
const result = await new Promise((resolve, reject) => {
|
|
1160
|
+
this.db.run(
|
|
1161
|
+
'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
1162
|
+
[req.session.userId, website_id, github_org, github_repo, git_branch, npm_package_id, version],
|
|
1163
|
+
function (err) {
|
|
1164
|
+
if (err) reject(err);
|
|
1165
|
+
else resolve(this.lastID);
|
|
1166
|
+
}
|
|
1167
|
+
);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
const taskId = result;
|
|
1171
|
+
|
|
1172
|
+
// Log the action
|
|
1173
|
+
this.logUserAction(req.session.userId, 'create_task', taskId.toString(), req.ip);
|
|
1174
|
+
|
|
1175
|
+
this.logger.info('Task created: ID=' + taskId + ' (' + npm_package_id + '#' + version + ') by user ' + req.session.userId);
|
|
1176
|
+
res.redirect('/publisher/tasks');
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
this.logger.error('Error creating task:', error);
|
|
1179
|
+
res.status(500).send('Failed to create task');
|
|
1180
|
+
}
|
|
1181
|
+
} finally {
|
|
1182
|
+
this.stats.countRequest('create-task', Date.now() - start);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async approveTask(req, res) {
|
|
1187
|
+
const start = Date.now();
|
|
1188
|
+
try {
|
|
1189
|
+
try {
|
|
1190
|
+
const taskId = req.params.id;
|
|
1191
|
+
|
|
1192
|
+
// Get task details
|
|
1193
|
+
const task = await this.getTask(taskId);
|
|
1194
|
+
if (!task) {
|
|
1195
|
+
return res.status(404).send('Task not found');
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (task.status !== 'waiting for approval') {
|
|
1199
|
+
return res.status(400).send('Task is not waiting for approval');
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Check user permissions
|
|
1203
|
+
const canApprove = await this.userCanApprove(req.session.userId, task.website_id);
|
|
1204
|
+
if (!canApprove) {
|
|
1205
|
+
return res.status(403).send('You do not have permission to approve tasks for this website');
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Update task status
|
|
1209
|
+
await new Promise((resolve, reject) => {
|
|
1210
|
+
this.db.run(
|
|
1211
|
+
'UPDATE tasks SET status = ?, approved_by = ?, publishing_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
1212
|
+
['publishing', req.session.userId, taskId],
|
|
1213
|
+
(err) => {
|
|
1214
|
+
if (err) reject(err);
|
|
1215
|
+
else resolve();
|
|
1216
|
+
}
|
|
1217
|
+
);
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
// Log the action
|
|
1221
|
+
this.logUserAction(req.session.userId, 'approve_task', taskId, req.ip);
|
|
1222
|
+
|
|
1223
|
+
this.logger.info('Task approved: ' + taskId + ' by user ' + req.session.userId);
|
|
1224
|
+
res.redirect('/publisher/tasks');
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
this.logger.error('Error approving task:', error);
|
|
1227
|
+
res.status(500).send('Failed to approve task');
|
|
1228
|
+
}
|
|
1229
|
+
} finally {
|
|
1230
|
+
this.stats.countRequest('approve-task', Date.now() - start);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async deleteTask(req, res) {
|
|
1235
|
+
const start = Date.now();
|
|
1236
|
+
try {
|
|
1237
|
+
try {
|
|
1238
|
+
const taskId = req.params.id;
|
|
1239
|
+
const task = await this.getTask(taskId);
|
|
1240
|
+
|
|
1241
|
+
if (!task) {
|
|
1242
|
+
return res.status(404).send('Task not found');
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (task.status !== 'waiting for approval' && task.status !== 'failed') {
|
|
1246
|
+
return res.status(400).send('Only tasks waiting for approval or failed can be deleted');
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Remove build output directory
|
|
1250
|
+
if (task.local_folder && fs.existsSync(task.local_folder)) {
|
|
1251
|
+
const rimraf = require('rimraf');
|
|
1252
|
+
await new Promise((resolve) => {
|
|
1253
|
+
rimraf(task.local_folder, (err) => {
|
|
1254
|
+
if (err) {
|
|
1255
|
+
this.logger.warn('Failed to remove task directory ' + task.local_folder + ': ' + err.message);
|
|
1256
|
+
}
|
|
1257
|
+
resolve(); // Continue even if directory removal fails
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Delete task logs
|
|
1263
|
+
await new Promise((resolve, reject) => {
|
|
1264
|
+
this.db.run('DELETE FROM task_logs WHERE task_id = ?', [taskId.toString()], (err) => {
|
|
1265
|
+
if (err) reject(err);
|
|
1266
|
+
else resolve();
|
|
1267
|
+
});
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
// Delete task record
|
|
1271
|
+
await new Promise((resolve, reject) => {
|
|
1272
|
+
this.db.run('DELETE FROM tasks WHERE id = ?', [taskId], (err) => {
|
|
1273
|
+
if (err) reject(err);
|
|
1274
|
+
else resolve();
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
this.logUserAction(req.session.userId, 'delete_task', taskId, req.ip);
|
|
1279
|
+
this.logger.info('Task deleted: ' + taskId + ' by admin ' + req.session.userId);
|
|
1280
|
+
res.redirect('/publisher/tasks');
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
this.logger.error('Error deleting task:', error);
|
|
1283
|
+
res.status(500).send('Failed to delete task');
|
|
1284
|
+
}
|
|
1285
|
+
} finally {
|
|
1286
|
+
this.stats.countRequest('delete-task', Date.now() - start);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async getTaskOutput(req, res) {
|
|
1291
|
+
const start = Date.now();
|
|
1292
|
+
try {
|
|
1293
|
+
|
|
1294
|
+
try {
|
|
1295
|
+
const taskId = req.params.id;
|
|
1296
|
+
const task = await this.getTask(taskId);
|
|
1297
|
+
|
|
1298
|
+
if (!task) {
|
|
1299
|
+
return res.status(404).send('Task not found');
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Get task logs
|
|
1303
|
+
const logs = await this.getTaskLogs(taskId);
|
|
1304
|
+
|
|
1305
|
+
// Get build log if available
|
|
1306
|
+
let buildLog = '';
|
|
1307
|
+
if (task.build_output_path && fs.existsSync(task.build_output_path)) {
|
|
1308
|
+
try {
|
|
1309
|
+
buildLog = fs.readFileSync(task.build_output_path, 'utf8');
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
buildLog = 'Error reading build log: ' + error.message;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (req.headers.accept && req.headers.accept.includes('text/html')) {
|
|
1316
|
+
const htmlServer = require('../library/html-server');
|
|
1317
|
+
let content = '<h3>Task Output: #' + task.id + ' - ' + task.npm_package_id + '#' + task.version + '</h3>';
|
|
1318
|
+
content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></p>';
|
|
1319
|
+
content += '<p><strong>GitHub:</strong> ' + task.github_org + '/' + task.github_repo + ' (' + task.git_branch + ')</p>';
|
|
1320
|
+
|
|
1321
|
+
if (task.local_folder) {
|
|
1322
|
+
content += '<p><strong>Local Folder:</strong> <code>' + task.local_folder + '</code></p>';
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (task.failure_reason) {
|
|
1326
|
+
content += '<div class="alert alert-danger"><strong>Failure Reason:</strong> ' + task.failure_reason + '</div>';
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Task logs section
|
|
1330
|
+
content += '<h4>Task Logs</h4>';
|
|
1331
|
+
if (logs.length === 0) {
|
|
1332
|
+
content += '<p>No task logs available yet.</p>';
|
|
1333
|
+
} else {
|
|
1334
|
+
content += '<div class="output-viewer" style="max-height: 300px;">';
|
|
1335
|
+
logs.forEach(log => {
|
|
1336
|
+
const timestamp = new Date(log.timestamp).toLocaleString();
|
|
1337
|
+
const levelClass = log.level === 'error' ? 'text-danger' : (log.level === 'warn' ? 'text-warning' : '');
|
|
1338
|
+
content += '<div class="' + levelClass + '">[' + timestamp + '] [' + log.level.toUpperCase() + '] ' + log.message + '</div>';
|
|
1339
|
+
});
|
|
1340
|
+
content += '</div>';
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Build log section
|
|
1344
|
+
if (buildLog) {
|
|
1345
|
+
content += '<h4>Build Log</h4>';
|
|
1346
|
+
content += '<div class="output-viewer">' + this.escapeHtml(buildLog) + '</div>';
|
|
1347
|
+
} else if (task.status === 'building') {
|
|
1348
|
+
content += '<h4>Build Log</h4>';
|
|
1349
|
+
content += '<p><em>Build in progress... Log will appear when available.</em></p>';
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
content += '<div class="mt-3"><a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a></div>';
|
|
1353
|
+
|
|
1354
|
+
const html = htmlServer.renderPage('publisher', 'Task Output - FHIR Publisher', content, {
|
|
1355
|
+
templateVars: {
|
|
1356
|
+
loginTitle: req.session.userId ? "Logout" : 'Login',
|
|
1357
|
+
loginPath: req.session.userId ? "logout" : 'login',
|
|
1358
|
+
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1359
|
+
}});
|
|
1360
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1361
|
+
res.send(html);
|
|
1362
|
+
} else {
|
|
1363
|
+
// Return plain text logs
|
|
1364
|
+
let output = 'Task #' + task.id + ' - ' + task.npm_package_id + '#' + task.version + '\n';
|
|
1365
|
+
output += 'Status: ' + task.status + '\n';
|
|
1366
|
+
output += 'GitHub: ' + task.github_org + '/' + task.github_repo + ' (' + task.git_branch + ')\n';
|
|
1367
|
+
|
|
1368
|
+
if (task.local_folder) {
|
|
1369
|
+
output += 'Local Folder: ' + task.local_folder + '\n';
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
output += '\n';
|
|
1373
|
+
|
|
1374
|
+
if (task.failure_reason) {
|
|
1375
|
+
output += 'Failure Reason: ' + task.failure_reason + '\n\n';
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
output += 'Task Logs:\n';
|
|
1379
|
+
logs.forEach(log => {
|
|
1380
|
+
const timestamp = new Date(log.timestamp).toLocaleString();
|
|
1381
|
+
output += '[' + timestamp + '] [' + log.level.toUpperCase() + '] ' + log.message + '\n';
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
if (buildLog) {
|
|
1385
|
+
output += '\n--- Build Log ---\n';
|
|
1386
|
+
output += buildLog;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
1390
|
+
res.send(output);
|
|
1391
|
+
}
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
this.logger.error('Error getting task output:', error);
|
|
1394
|
+
res.status(500).send('Failed to get task output');
|
|
1395
|
+
}
|
|
1396
|
+
} finally {
|
|
1397
|
+
this.stats.countRequest('task-output', Date.now() - start);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
async getTaskQA(req, res) {
|
|
1402
|
+
const start = Date.now();
|
|
1403
|
+
try {
|
|
1404
|
+
const taskId = req.params.id;
|
|
1405
|
+
res.redirect('/publisher/tasks/' + taskId + '/qa-files/qa.html');
|
|
1406
|
+
} finally {
|
|
1407
|
+
this.stats.countRequest('task-qa', Date.now() - start);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
async getTaskHistory(req, res) {
|
|
1412
|
+
const start = Date.now();
|
|
1413
|
+
try {
|
|
1414
|
+
try {
|
|
1415
|
+
const taskId = req.params.id;
|
|
1416
|
+
const task = await this.getTask(taskId);
|
|
1417
|
+
|
|
1418
|
+
if (!task) {
|
|
1419
|
+
return res.status(404).send('Task not found');
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const logs = await this.getTaskLogs(taskId);
|
|
1423
|
+
const actions = await this.getTaskActions(taskId);
|
|
1424
|
+
|
|
1425
|
+
const htmlServer = require('../library/html-server');
|
|
1426
|
+
|
|
1427
|
+
let content = '<h3>Task History: #' + task.id + ' — ' + this.escapeHtml(task.npm_package_id) + '#' + this.escapeHtml(task.version) + '</h3>';
|
|
1428
|
+
|
|
1429
|
+
// Task details summary card
|
|
1430
|
+
content += '<div class="card mb-4"><div class="card-body">';
|
|
1431
|
+
content += '<div class="row">';
|
|
1432
|
+
content += '<div class="col-md-6">';
|
|
1433
|
+
content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></p>';
|
|
1434
|
+
content += '<p><strong>Package:</strong> <code>' + this.escapeHtml(task.npm_package_id) + '</code></p>';
|
|
1435
|
+
content += '<p><strong>Version:</strong> ' + this.escapeHtml(task.version) + '</p>';
|
|
1436
|
+
content += '<p><strong>Website:</strong> ' + this.escapeHtml(task.website_name) + '</p>';
|
|
1437
|
+
content += '</div>';
|
|
1438
|
+
content += '<div class="col-md-6">';
|
|
1439
|
+
content += '<p><strong>GitHub:</strong> ' + this.escapeHtml(task.github_org) + '/' + this.escapeHtml(task.github_repo) + ' (' + this.escapeHtml(task.git_branch) + ')</p>';
|
|
1440
|
+
content += '<p><strong>Created by:</strong> ' + this.escapeHtml(task.user_name) + ' (' + this.escapeHtml(task.user_login) + ')</p>';
|
|
1441
|
+
if (task.approved_by_name) {
|
|
1442
|
+
content += '<p><strong>Approved by:</strong> ' + this.escapeHtml(task.approved_by_name) + '</p>';
|
|
1443
|
+
}
|
|
1444
|
+
if (task.local_folder) {
|
|
1445
|
+
content += '<p><strong>Local folder:</strong> <code>' + this.escapeHtml(task.local_folder) + '</code></p>';
|
|
1446
|
+
}
|
|
1447
|
+
if (task.failure_reason) {
|
|
1448
|
+
content += '<p><strong>Failure reason:</strong> <span class="text-danger">' + this.escapeHtml(task.failure_reason) + '</span></p>';
|
|
1449
|
+
}
|
|
1450
|
+
content += '</div>';
|
|
1451
|
+
content += '</div>';
|
|
1452
|
+
content += '</div></div>';
|
|
1453
|
+
|
|
1454
|
+
// Announcement section (for completed publications)
|
|
1455
|
+
if (task.announcement) {
|
|
1456
|
+
content += '<div class="card mb-4"><div class="card-body">';
|
|
1457
|
+
content += '<h5>Announcement</h5>';
|
|
1458
|
+
content += '<pre class="mb-0" style="white-space: pre-wrap;">' + this.escapeHtml(task.announcement) + '</pre>';
|
|
1459
|
+
content += '</div></div>';
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// Build unified timeline from all sources
|
|
1463
|
+
const events = [];
|
|
1464
|
+
|
|
1465
|
+
// Status transition timestamps from the task record
|
|
1466
|
+
if (task.queued_at) {
|
|
1467
|
+
events.push({ timestamp: task.queued_at, type: 'status', icon: '📋', label: 'Task queued', detail: 'Created by ' + this.escapeHtml(task.user_name), css: '' });
|
|
1468
|
+
}
|
|
1469
|
+
if (task.building_at) {
|
|
1470
|
+
events.push({ timestamp: task.building_at, type: 'status', icon: '🔨', label: 'Draft build started', detail: '', css: '' });
|
|
1471
|
+
}
|
|
1472
|
+
if (task.waiting_approval_at) {
|
|
1473
|
+
events.push({ timestamp: task.waiting_approval_at, type: 'status', icon: '⏳', label: 'Waiting for approval', detail: 'Draft build completed', css: '' });
|
|
1474
|
+
}
|
|
1475
|
+
if (task.publishing_at) {
|
|
1476
|
+
const approver = task.approved_by_name ? 'Approved by ' + this.escapeHtml(task.approved_by_name) : '';
|
|
1477
|
+
events.push({ timestamp: task.publishing_at, type: 'status', icon: '🚀', label: 'Publishing started', detail: approver, css: '' });
|
|
1478
|
+
}
|
|
1479
|
+
if (task.completed_at) {
|
|
1480
|
+
events.push({ timestamp: task.completed_at, type: 'status', icon: '✅', label: 'Completed', detail: '', css: 'text-success' });
|
|
1481
|
+
}
|
|
1482
|
+
if (task.failed_at) {
|
|
1483
|
+
events.push({ timestamp: task.failed_at, type: 'status', icon: '❌', label: 'Failed', detail: task.failure_reason ? this.escapeHtml(task.failure_reason) : '', css: 'text-danger' });
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Task log entries
|
|
1487
|
+
for (const log of logs) {
|
|
1488
|
+
events.push({
|
|
1489
|
+
timestamp: log.timestamp,
|
|
1490
|
+
type: 'log',
|
|
1491
|
+
icon: log.level === 'error' ? '🔴' : log.level === 'warn' ? '🟡' : '🔵',
|
|
1492
|
+
label: this.escapeHtml(log.message),
|
|
1493
|
+
detail: '',
|
|
1494
|
+
css: log.level === 'error' ? 'text-danger' : log.level === 'warn' ? 'text-warning' : 'text-muted'
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// User actions
|
|
1499
|
+
for (const action of actions) {
|
|
1500
|
+
const who = action.user_name ? this.escapeHtml(action.user_name) + ' (' + this.escapeHtml(action.user_login) + ')' : 'Unknown';
|
|
1501
|
+
const ip = action.ip_address ? ' from ' + this.escapeHtml(action.ip_address) : '';
|
|
1502
|
+
let actionLabel = action.action;
|
|
1503
|
+
if (action.action === 'create_task') actionLabel = 'Created task';
|
|
1504
|
+
else if (action.action === 'approve_task') actionLabel = 'Approved task';
|
|
1505
|
+
else if (action.action === 'delete_task') actionLabel = 'Deleted task';
|
|
1506
|
+
else actionLabel = action.action.replace(/_/g, ' ');
|
|
1507
|
+
|
|
1508
|
+
events.push({
|
|
1509
|
+
timestamp: action.timestamp,
|
|
1510
|
+
type: 'action',
|
|
1511
|
+
icon: '👤',
|
|
1512
|
+
label: actionLabel,
|
|
1513
|
+
detail: who + ip,
|
|
1514
|
+
css: ''
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Sort all events chronologically
|
|
1519
|
+
events.sort((a, b) => {
|
|
1520
|
+
const ta = new Date(a.timestamp).getTime();
|
|
1521
|
+
const tb = new Date(b.timestamp).getTime();
|
|
1522
|
+
if (ta !== tb) return ta - tb;
|
|
1523
|
+
// Within the same timestamp, put status transitions first, then actions, then logs
|
|
1524
|
+
const order = { status: 0, action: 1, log: 2 };
|
|
1525
|
+
return (order[a.type] || 9) - (order[b.type] || 9);
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
// Render timeline
|
|
1529
|
+
content += '<h4>Timeline</h4>';
|
|
1530
|
+
if (events.length === 0) {
|
|
1531
|
+
content += '<p>No history recorded yet.</p>';
|
|
1532
|
+
} else {
|
|
1533
|
+
content += '<table class="table table-sm">';
|
|
1534
|
+
content += '<thead><tr><th style="width: 180px;">Time</th><th style="width: 30px;"></th><th style="width: 100px;">Type</th><th>Event</th></tr></thead>';
|
|
1535
|
+
content += '<tbody>';
|
|
1536
|
+
for (const evt of events) {
|
|
1537
|
+
const ts = new Date(evt.timestamp).toLocaleString();
|
|
1538
|
+
const typeBadge = evt.type === 'status' ? '<span class="badge bg-primary">status</span>'
|
|
1539
|
+
: evt.type === 'action' ? '<span class="badge bg-info">user</span>'
|
|
1540
|
+
: '<span class="badge bg-secondary">log</span>';
|
|
1541
|
+
content += '<tr>';
|
|
1542
|
+
content += '<td class="text-nowrap"><small>' + ts + '</small></td>';
|
|
1543
|
+
content += '<td>' + evt.icon + '</td>';
|
|
1544
|
+
content += '<td>' + typeBadge + '</td>';
|
|
1545
|
+
content += '<td class="' + evt.css + '">' + evt.label;
|
|
1546
|
+
if (evt.detail) {
|
|
1547
|
+
content += ' <small class="text-muted">— ' + evt.detail + '</small>';
|
|
1548
|
+
}
|
|
1549
|
+
content += '</td>';
|
|
1550
|
+
content += '</tr>';
|
|
1551
|
+
}
|
|
1552
|
+
content += '</tbody></table>';
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// Links at the bottom
|
|
1556
|
+
content += '<div class="mt-3">';
|
|
1557
|
+
if (task.build_output_path) {
|
|
1558
|
+
content += '<a href="/publisher/tasks/' + task.id + '/output" class="btn btn-outline-info me-2">View Build Output</a>';
|
|
1559
|
+
}
|
|
1560
|
+
if (task.status === 'waiting for approval') {
|
|
1561
|
+
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-outline-secondary me-2">View QA Report</a>';
|
|
1562
|
+
}
|
|
1563
|
+
content += '<a href="/publisher/tasks" class="btn btn-secondary">Back to Tasks</a>';
|
|
1564
|
+
content += '</div>';
|
|
1565
|
+
|
|
1566
|
+
const html = htmlServer.renderPage('publisher', 'Task History - FHIR Publisher', content, {
|
|
1567
|
+
templateVars: {
|
|
1568
|
+
loginTitle: req.session.userId ? "Logout" : 'Login',
|
|
1569
|
+
loginPath: req.session.userId ? "logout" : 'login',
|
|
1570
|
+
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1571
|
+
}});
|
|
1572
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1573
|
+
res.send(html);
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
this.logger.error('Error rendering task history:', error);
|
|
1576
|
+
res.status(500).send('Internal server error');
|
|
1577
|
+
}
|
|
1578
|
+
} finally {
|
|
1579
|
+
this.stats.countRequest('task-history', Date.now() - start);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
escapeHtml(text) {
|
|
1584
|
+
return text
|
|
1585
|
+
.replace(/&/g, '&')
|
|
1586
|
+
.replace(/</g, '<')
|
|
1587
|
+
.replace(/>/g, '>')
|
|
1588
|
+
.replace(/"/g, '"')
|
|
1589
|
+
.replace(/'/g, ''');
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
async renderWebsites(req, res) {
|
|
1593
|
+
const start = Date.now();
|
|
1594
|
+
try {
|
|
1595
|
+
try {
|
|
1596
|
+
const htmlServer = require('../library/html-server');
|
|
1597
|
+
const websites = await this.getWebsites();
|
|
1598
|
+
|
|
1599
|
+
let content = '<div class="row mb-4">';
|
|
1600
|
+
|
|
1601
|
+
// Add website form
|
|
1602
|
+
content += '<div class="col-12 mb-4">';
|
|
1603
|
+
content += '<button class="btn btn-primary" onclick="document.getElementById(\'add-website-panel\').style.display = document.getElementById(\'add-website-panel\').style.display === \'none\' ? \'block\' : \'none\'">Add New Website</button>';
|
|
1604
|
+
content += '<div id="add-website-panel" style="display: none;" class="mt-3">';
|
|
1605
|
+
content += '<h3>Add New Website</h3>';
|
|
1606
|
+
content += '<form method="post" action="/publisher/admin/websites" class="row g-3">';
|
|
1607
|
+
content += '<div class="col-md-4">';
|
|
1608
|
+
content += '<label for="name" class="form-label">Website Name</label>';
|
|
1609
|
+
content += '<input type="text" class="form-control" id="name" name="name" required>';
|
|
1610
|
+
content += '</div>';
|
|
1611
|
+
content += '<div class="col-md-4">';
|
|
1612
|
+
content += '<label for="local_folder" class="form-label">Local Folder</label>';
|
|
1613
|
+
content += '<input type="text" class="form-control" id="local_folder" name="local_folder" required>';
|
|
1614
|
+
content += '</div>';
|
|
1615
|
+
content += '<div class="col-md-4">';
|
|
1616
|
+
content += '<label for="server_update_script" class="form-label">Update Script</label>';
|
|
1617
|
+
content += '<input type="text" class="form-control" id="server_update_script" name="server_update_script" required>';
|
|
1618
|
+
content += '</div>';
|
|
1619
|
+
content += '<div class="col-12">';
|
|
1620
|
+
content += '<button type="submit" class="btn btn-primary">Add Website</button>';
|
|
1621
|
+
content += '</div>';
|
|
1622
|
+
content += '</form>';
|
|
1623
|
+
content += '</div>'; // add-website-panel
|
|
1624
|
+
content += '</div>';
|
|
1625
|
+
|
|
1626
|
+
// Websites list
|
|
1627
|
+
content += '<div class="col-12">';
|
|
1628
|
+
content += '<h3>Websites</h3>';
|
|
1629
|
+
|
|
1630
|
+
if (websites.length === 0) {
|
|
1631
|
+
content += '<p>No websites configured.</p>';
|
|
1632
|
+
} else {
|
|
1633
|
+
content += '<div class="table-responsive">';
|
|
1634
|
+
content += '<table class="table table-striped">';
|
|
1635
|
+
content += '<thead><tr><th>Name</th><th>Local Folder</th><th>Update Script</th><th>Active</th><th>Created</th></tr></thead>';
|
|
1636
|
+
content += '<tbody>';
|
|
1637
|
+
|
|
1638
|
+
websites.forEach(website => {
|
|
1639
|
+
content += '<tr>';
|
|
1640
|
+
content += '<td>' + website.name + '</td>';
|
|
1641
|
+
content += '<td><code>' + website.local_folder + '</code></td>';
|
|
1642
|
+
content += '<td><code>' + website.server_update_script + '</code></td>';
|
|
1643
|
+
content += '<td>' + (website.is_active ? '✓' : '✗') + '</td>';
|
|
1644
|
+
content += '<td>' + new Date(website.created_at).toLocaleString() + '</td>';
|
|
1645
|
+
content += '</tr>';
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
content += '</tbody></table>';
|
|
1649
|
+
content += '</div>';
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
content += '</div>';
|
|
1653
|
+
content += '</div>';
|
|
1654
|
+
|
|
1655
|
+
const html = htmlServer.renderPage('publisher', 'Websites - FHIR Publisher', content, {
|
|
1656
|
+
templateVars: {
|
|
1657
|
+
loginTitle: req.session.userId ? "Logout" : 'Login',
|
|
1658
|
+
loginPath: req.session.userId ? "logout" : 'login',
|
|
1659
|
+
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1660
|
+
}});
|
|
1661
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1662
|
+
res.send(html);
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
this.logger.error('Error rendering websites:', error);
|
|
1665
|
+
res.status(500).send('Internal server error');
|
|
1666
|
+
}
|
|
1667
|
+
} finally {
|
|
1668
|
+
this.stats.countRequest('websites', Date.now() - start);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
async createWebsite(req, res) {
|
|
1673
|
+
const start = Date.now();
|
|
1674
|
+
try {
|
|
1675
|
+
try {
|
|
1676
|
+
const {name, local_folder, server_update_script} = req.body;
|
|
1677
|
+
|
|
1678
|
+
await new Promise((resolve, reject) => {
|
|
1679
|
+
this.db.run(
|
|
1680
|
+
'INSERT INTO websites (name, local_folder, server_update_script) VALUES (?, ?, ?)',
|
|
1681
|
+
[name, local_folder, server_update_script],
|
|
1682
|
+
function (err) {
|
|
1683
|
+
if (err) reject(err);
|
|
1684
|
+
else resolve();
|
|
1685
|
+
}
|
|
1686
|
+
);
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1689
|
+
this.logUserAction(req.session.userId, 'create_website', name, req.ip);
|
|
1690
|
+
this.logger.info('Website created: ' + name + ' by user ' + req.session.userId);
|
|
1691
|
+
|
|
1692
|
+
res.redirect('/publisher/admin/websites');
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
this.logger.error('Error creating website:', error);
|
|
1695
|
+
res.status(500).send('Failed to create website');
|
|
1696
|
+
}
|
|
1697
|
+
} finally {
|
|
1698
|
+
this.stats.countRequest('website', Date.now() - start);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
async renderUsers(req, res) {
|
|
1703
|
+
const start = Date.now();
|
|
1704
|
+
try {
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
try {
|
|
1708
|
+
const htmlServer = require('../library/html-server');
|
|
1709
|
+
const users = await this.getUsers();
|
|
1710
|
+
const websites = await this.getWebsites();
|
|
1711
|
+
|
|
1712
|
+
let content = '<div class="row mb-4">';
|
|
1713
|
+
|
|
1714
|
+
// Add user form
|
|
1715
|
+
content += '<div class="col-12 mb-4">';
|
|
1716
|
+
content += '<button class="btn btn-primary" onclick="document.getElementById(\'add-user-panel\').style.display = document.getElementById(\'add-user-panel\').style.display === \'none\' ? \'block\' : \'none\'">Add New User</button>';
|
|
1717
|
+
content += '<div id="add-user-panel" style="display: none;" class="mt-3">';
|
|
1718
|
+
content += '<h3>Add New User</h3>';
|
|
1719
|
+
content += '<form method="post" action="/publisher/admin/users" class="row g-3">';
|
|
1720
|
+
content += '<div class="col-md-3">';
|
|
1721
|
+
content += '<label for="name" class="form-label">Full Name</label>';
|
|
1722
|
+
content += '<input type="text" class="form-control" id="name" name="name" required>';
|
|
1723
|
+
content += '</div>';
|
|
1724
|
+
content += '<div class="col-md-3">';
|
|
1725
|
+
content += '<label for="login" class="form-label">Username</label>';
|
|
1726
|
+
content += '<input type="text" class="form-control" id="login" name="login" required>';
|
|
1727
|
+
content += '</div>';
|
|
1728
|
+
content += '<div class="col-md-3">';
|
|
1729
|
+
content += '<label for="password" class="form-label">Password</label>';
|
|
1730
|
+
content += '<input type="password" class="form-control" id="password" name="password" required>';
|
|
1731
|
+
content += '</div>';
|
|
1732
|
+
content += '<div class="col-md-3 d-flex align-items-end">';
|
|
1733
|
+
content += '<div class="form-check">';
|
|
1734
|
+
content += '<input class="form-check-input" type="checkbox" id="is_admin" name="is_admin">';
|
|
1735
|
+
content += '<label class="form-check-label" for="is_admin">Administrator</label>';
|
|
1736
|
+
content += '</div>';
|
|
1737
|
+
content += '</div>';
|
|
1738
|
+
content += '<div class="col-12">';
|
|
1739
|
+
content += '<button type="submit" class="btn btn-primary">Add User</button>';
|
|
1740
|
+
content += '</div>';
|
|
1741
|
+
content += '</form>';
|
|
1742
|
+
content += '</div>'; // add-user-panel
|
|
1743
|
+
content += '</div>';
|
|
1744
|
+
|
|
1745
|
+
// Users list
|
|
1746
|
+
content += '<div class="col-12">';
|
|
1747
|
+
content += '<h3>Users & Permissions</h3>';
|
|
1748
|
+
|
|
1749
|
+
if (users.length === 0) {
|
|
1750
|
+
content += '<p>No users found.</p>';
|
|
1751
|
+
} else {
|
|
1752
|
+
for (const user of users) {
|
|
1753
|
+
const permissions = await this.getUserPermissions(user.id);
|
|
1754
|
+
|
|
1755
|
+
content += '<div class="card mb-3">';
|
|
1756
|
+
content += '<div class="card-header">';
|
|
1757
|
+
content += '<h5>' + user.name + ' (' + user.login + ') ' + (user.is_admin ? '<span class="badge bg-warning">Admin</span>' : '') + '</h5>';
|
|
1758
|
+
content += '</div>';
|
|
1759
|
+
content += '<div class="card-body">';
|
|
1760
|
+
|
|
1761
|
+
if (websites.length === 0) {
|
|
1762
|
+
content += '<p>No websites available for permission assignment.</p>';
|
|
1763
|
+
} else {
|
|
1764
|
+
content += '<form method="post" action="/publisher/admin/permissions">';
|
|
1765
|
+
content += '<input type="hidden" name="user_id" value="' + user.id + '">';
|
|
1766
|
+
content += '<div class="permission-grid">';
|
|
1767
|
+
content += '<div><strong>Website</strong></div>';
|
|
1768
|
+
content += '<div><strong>Can Queue</strong></div>';
|
|
1769
|
+
content += '<div><strong>Can Approve</strong></div>';
|
|
1770
|
+
|
|
1771
|
+
websites.forEach(website => {
|
|
1772
|
+
const perm = permissions.find(p => p.website_id === website.id) || {};
|
|
1773
|
+
content += '<div>' + website.name + '</div>';
|
|
1774
|
+
content += '<div>';
|
|
1775
|
+
content += '<input type="checkbox" name="queue_' + website.id + '"' + (perm.can_queue ? ' checked' : '') + '>';
|
|
1776
|
+
content += '</div>';
|
|
1777
|
+
content += '<div>';
|
|
1778
|
+
content += '<input type="checkbox" name="approve_' + website.id + '"' + (perm.can_approve ? ' checked' : '') + '>';
|
|
1779
|
+
content += '</div>';
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
content += '</div>';
|
|
1783
|
+
content += '<div class="mt-3">';
|
|
1784
|
+
content += '<button type="submit" class="btn btn-secondary btn-sm">Update Permissions</button>';
|
|
1785
|
+
content += '</div>';
|
|
1786
|
+
content += '</form>';
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
content += '</div>';
|
|
1790
|
+
content += '</div>';
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
content += '</div>';
|
|
1795
|
+
content += '</div>';
|
|
1796
|
+
|
|
1797
|
+
const html = htmlServer.renderPage('publisher', 'Users - FHIR Publisher', content, {
|
|
1798
|
+
templateVars: {
|
|
1799
|
+
loginTitle: req.session.userId ? "Logout" : 'Login',
|
|
1800
|
+
loginPath: req.session.userId ? "logout" : 'login',
|
|
1801
|
+
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1802
|
+
}});
|
|
1803
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1804
|
+
res.send(html);
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
this.logger.error('Error rendering users:', error);
|
|
1807
|
+
res.status(500).send('Internal server error');
|
|
1808
|
+
}
|
|
1809
|
+
} finally {
|
|
1810
|
+
this.stats.countRequest('users', Date.now() - start);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
async createUser(req, res) {
|
|
1815
|
+
const start = Date.now();
|
|
1816
|
+
try {
|
|
1817
|
+
try {
|
|
1818
|
+
const {name, login, password, is_admin} = req.body;
|
|
1819
|
+
|
|
1820
|
+
const passwordHash = await bcrypt.hash(password, 10);
|
|
1821
|
+
|
|
1822
|
+
await new Promise((resolve, reject) => {
|
|
1823
|
+
this.db.run(
|
|
1824
|
+
'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
|
|
1825
|
+
[name, login, passwordHash, is_admin ? 1 : 0],
|
|
1826
|
+
function (err) {
|
|
1827
|
+
if (err) reject(err);
|
|
1828
|
+
else resolve();
|
|
1829
|
+
}
|
|
1830
|
+
);
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
this.logUserAction(req.session.userId, 'create_user', login, req.ip);
|
|
1834
|
+
this.logger.info('User created: ' + login + ' by user ' + req.session.userId);
|
|
1835
|
+
|
|
1836
|
+
res.redirect('/publisher/admin/users');
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
this.logger.error('Error creating user:', error);
|
|
1839
|
+
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
|
1840
|
+
res.status(400).send('Username already exists');
|
|
1841
|
+
} else {
|
|
1842
|
+
res.status(500).send('Failed to create user');
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
} finally {
|
|
1846
|
+
this.stats.countRequest('createUser', Date.now() - start);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
async updatePermissions(req, res) {
|
|
1851
|
+
const start = Date.now();
|
|
1852
|
+
try {
|
|
1853
|
+
|
|
1854
|
+
try {
|
|
1855
|
+
const {user_id} = req.body;
|
|
1856
|
+
const websites = await this.getWebsites();
|
|
1857
|
+
|
|
1858
|
+
// Clear existing permissions
|
|
1859
|
+
await new Promise((resolve, reject) => {
|
|
1860
|
+
this.db.run('DELETE FROM user_website_permissions WHERE user_id = ?', [user_id], (err) => {
|
|
1861
|
+
if (err) reject(err);
|
|
1862
|
+
else resolve();
|
|
1863
|
+
});
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
// Add new permissions
|
|
1867
|
+
for (const website of websites) {
|
|
1868
|
+
const canQueue = req.body['queue_' + website.id] === 'on';
|
|
1869
|
+
const canApprove = req.body['approve_' + website.id] === 'on';
|
|
1870
|
+
|
|
1871
|
+
if (canQueue || canApprove) {
|
|
1872
|
+
await new Promise((resolve, reject) => {
|
|
1873
|
+
this.db.run(
|
|
1874
|
+
'INSERT INTO user_website_permissions (user_id, website_id, can_queue, can_approve) VALUES (?, ?, ?, ?)',
|
|
1875
|
+
[user_id, website.id, canQueue ? 1 : 0, canApprove ? 1 : 0],
|
|
1876
|
+
(err) => {
|
|
1877
|
+
if (err) reject(err);
|
|
1878
|
+
else resolve();
|
|
1879
|
+
}
|
|
1880
|
+
);
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
this.logUserAction(req.session.userId, 'update_permissions', user_id, req.ip);
|
|
1886
|
+
res.redirect('/publisher/admin/users');
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
this.logger.error('Error updating permissions:', error);
|
|
1889
|
+
res.status(500).send('Failed to update permissions');
|
|
1890
|
+
}
|
|
1891
|
+
} finally {
|
|
1892
|
+
this.stats.countRequest('update', Date.now() - start);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// Helper methods
|
|
1897
|
+
async getTasks(limit = null) {
|
|
1898
|
+
return new Promise((resolve, reject) => {
|
|
1899
|
+
let sql = 'SELECT t.*, u.name as user_name, w.name as website_name, approver.name as approved_by_name FROM tasks t JOIN users u ON t.user_id = u.id JOIN websites w ON t.website_id = w.id LEFT JOIN users approver ON t.approved_by = approver.id ORDER BY t.queued_at DESC';
|
|
1900
|
+
|
|
1901
|
+
if (limit) {
|
|
1902
|
+
sql += ' LIMIT ' + limit;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
this.db.all(sql, (err, rows) => {
|
|
1906
|
+
if (err) reject(err);
|
|
1907
|
+
else resolve(rows || []);
|
|
1908
|
+
});
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
async getTask(taskId) {
|
|
1913
|
+
return new Promise((resolve, reject) => {
|
|
1914
|
+
this.db.get(
|
|
1915
|
+
'SELECT t.*, u.name as user_name, u.login as user_login, w.name as website_name, approver.name as approved_by_name FROM tasks t JOIN users u ON t.user_id = u.id JOIN websites w ON t.website_id = w.id LEFT JOIN users approver ON t.approved_by = approver.id WHERE t.id = ?',
|
|
1916
|
+
[taskId],
|
|
1917
|
+
(err, row) => {
|
|
1918
|
+
if (err) reject(err);
|
|
1919
|
+
else resolve(row);
|
|
1920
|
+
}
|
|
1921
|
+
);
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
async getTaskLogs(taskId) {
|
|
1926
|
+
return new Promise((resolve, reject) => {
|
|
1927
|
+
this.db.all(
|
|
1928
|
+
'SELECT * FROM task_logs WHERE task_id = ? ORDER BY timestamp ASC',
|
|
1929
|
+
[taskId.toString()],
|
|
1930
|
+
(err, rows) => {
|
|
1931
|
+
if (err) reject(err);
|
|
1932
|
+
else resolve(rows || []);
|
|
1933
|
+
}
|
|
1934
|
+
);
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
async getTaskActions(taskId) {
|
|
1939
|
+
return new Promise((resolve, reject) => {
|
|
1940
|
+
this.db.all(
|
|
1941
|
+
'SELECT ua.*, u.name as user_name, u.login as user_login FROM user_actions ua LEFT JOIN users u ON ua.user_id = u.id WHERE ua.target_id = ? ORDER BY ua.timestamp ASC',
|
|
1942
|
+
[taskId.toString()],
|
|
1943
|
+
(err, rows) => {
|
|
1944
|
+
if (err) reject(err);
|
|
1945
|
+
else resolve(rows || []);
|
|
1946
|
+
}
|
|
1947
|
+
);
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
async getUserWebsites(userId) {
|
|
1952
|
+
return new Promise((resolve, reject) => {
|
|
1953
|
+
this.db.all(
|
|
1954
|
+
'SELECT w.* FROM websites w JOIN user_website_permissions p ON w.id = p.website_id WHERE p.user_id = ? AND p.can_queue = 1 AND w.is_active = 1',
|
|
1955
|
+
[userId],
|
|
1956
|
+
(err, rows) => {
|
|
1957
|
+
if (err) reject(err);
|
|
1958
|
+
else resolve(rows || []);
|
|
1959
|
+
}
|
|
1960
|
+
);
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
async userCanQueue(userId, websiteId) {
|
|
1965
|
+
return new Promise((resolve, reject) => {
|
|
1966
|
+
this.db.get(
|
|
1967
|
+
'SELECT can_queue FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
|
|
1968
|
+
[userId, websiteId],
|
|
1969
|
+
(err, row) => {
|
|
1970
|
+
if (err) reject(err);
|
|
1971
|
+
else resolve(row && row.can_queue);
|
|
1972
|
+
}
|
|
1973
|
+
);
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
async userCanApprove(userId, websiteId) {
|
|
1978
|
+
return new Promise((resolve, reject) => {
|
|
1979
|
+
this.db.get(
|
|
1980
|
+
'SELECT can_approve FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
|
|
1981
|
+
[userId, websiteId],
|
|
1982
|
+
(err, row) => {
|
|
1983
|
+
if (err) reject(err);
|
|
1984
|
+
else resolve(row && row.can_approve);
|
|
1985
|
+
}
|
|
1986
|
+
);
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
async findActiveTask(packageId, version) {
|
|
1991
|
+
return new Promise((resolve, reject) => {
|
|
1992
|
+
this.db.get(
|
|
1993
|
+
'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? AND status NOT IN (?, ?) ORDER BY queued_at DESC LIMIT 1',
|
|
1994
|
+
[packageId, version, 'complete', 'failed'],
|
|
1995
|
+
(err, row) => {
|
|
1996
|
+
if (err) reject(err);
|
|
1997
|
+
else resolve(row);
|
|
1998
|
+
}
|
|
1999
|
+
);
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
async findExistingTask(packageId, version) {
|
|
2004
|
+
return new Promise((resolve, reject) => {
|
|
2005
|
+
this.db.get(
|
|
2006
|
+
'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? ORDER BY queued_at DESC LIMIT 1',
|
|
2007
|
+
[packageId, version],
|
|
2008
|
+
(err, row) => {
|
|
2009
|
+
if (err) reject(err);
|
|
2010
|
+
else resolve(row);
|
|
2011
|
+
}
|
|
2012
|
+
);
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
async getWebsites() {
|
|
2017
|
+
return new Promise((resolve, reject) => {
|
|
2018
|
+
this.db.all('SELECT * FROM websites ORDER BY name', (err, rows) => {
|
|
2019
|
+
if (err) reject(err);
|
|
2020
|
+
else resolve(rows || []);
|
|
2021
|
+
});
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
async getWebsite(websiteId) {
|
|
2026
|
+
return new Promise((resolve, reject) => {
|
|
2027
|
+
this.db.get('SELECT * FROM websites WHERE id = ?', [websiteId], (err, row) => {
|
|
2028
|
+
if (err) reject(err);
|
|
2029
|
+
else resolve(row);
|
|
2030
|
+
});
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
async runCommand(command, args, options, taskId, description) {
|
|
2035
|
+
const { spawn } = require('child_process');
|
|
2036
|
+
|
|
2037
|
+
await this.logTaskMessage(taskId, 'info', description);
|
|
2038
|
+
|
|
2039
|
+
return new Promise((resolve, reject) => {
|
|
2040
|
+
const proc = spawn(command, args, {
|
|
2041
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2042
|
+
...options
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
let stdout = '';
|
|
2046
|
+
let stderr = '';
|
|
2047
|
+
|
|
2048
|
+
proc.stdout.on('data', (data) => {
|
|
2049
|
+
stdout += data.toString();
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
proc.stderr.on('data', (data) => {
|
|
2053
|
+
stderr += data.toString();
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
proc.on('close', async (code) => {
|
|
2057
|
+
if (code === 0) {
|
|
2058
|
+
resolve(stdout);
|
|
2059
|
+
} else {
|
|
2060
|
+
const error = description + ' failed with code ' + code + ': ' + (stderr || stdout);
|
|
2061
|
+
await this.logTaskMessage(taskId, 'error', error);
|
|
2062
|
+
reject(new Error(error));
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
proc.on('error', async (error) => {
|
|
2067
|
+
await this.logTaskMessage(taskId, 'error', description + ' error: ' + error.message);
|
|
2068
|
+
reject(error);
|
|
2069
|
+
});
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
async getUsers() {
|
|
2074
|
+
return new Promise((resolve, reject) => {
|
|
2075
|
+
this.db.all('SELECT id, name, login, is_admin, created_at FROM users ORDER BY name', (err, rows) => {
|
|
2076
|
+
if (err) reject(err);
|
|
2077
|
+
else resolve(rows || []);
|
|
2078
|
+
});
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
async getUserPermissions(userId) {
|
|
2083
|
+
return new Promise((resolve, reject) => {
|
|
2084
|
+
this.db.all(
|
|
2085
|
+
'SELECT * FROM user_website_permissions WHERE user_id = ?',
|
|
2086
|
+
[userId],
|
|
2087
|
+
(err, rows) => {
|
|
2088
|
+
if (err) reject(err);
|
|
2089
|
+
else resolve(rows || []);
|
|
2090
|
+
}
|
|
2091
|
+
);
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
async shutdown() {
|
|
2096
|
+
this.logger.info('Shutting down publisher module...');
|
|
2097
|
+
|
|
2098
|
+
// Stop accepting new tasks
|
|
2099
|
+
this.shutdownRequested = true;
|
|
2100
|
+
|
|
2101
|
+
// Stop the task processor
|
|
2102
|
+
this.stopTaskProcessor();
|
|
2103
|
+
|
|
2104
|
+
// Wait for current task to finish (with timeout)
|
|
2105
|
+
const maxWaitTime = 30000; // 30 seconds
|
|
2106
|
+
const startTime = Date.now();
|
|
2107
|
+
|
|
2108
|
+
while (this.isProcessing && (Date.now() - startTime) < maxWaitTime) {
|
|
2109
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
if (this.isProcessing) {
|
|
2113
|
+
this.logger.warn('Task processor did not finish within timeout period');
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// Close database
|
|
2117
|
+
if (this.db) {
|
|
2118
|
+
return new Promise((resolve) => {
|
|
2119
|
+
this.db.close((err) => {
|
|
2120
|
+
if (err) {
|
|
2121
|
+
this.logger.error('Error closing database:', err);
|
|
2122
|
+
} else {
|
|
2123
|
+
this.logger.info('Database closed');
|
|
2124
|
+
}
|
|
2125
|
+
resolve();
|
|
2126
|
+
});
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
getStatusColor(status) {
|
|
2132
|
+
const colors = {
|
|
2133
|
+
'queued': 'secondary',
|
|
2134
|
+
'building': 'warning',
|
|
2135
|
+
'waiting for approval': 'info',
|
|
2136
|
+
'publishing': 'warning',
|
|
2137
|
+
'complete': 'success',
|
|
2138
|
+
'failed': 'danger'
|
|
2139
|
+
};
|
|
2140
|
+
return colors[status] || 'secondary';
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
async logUserAction(userId, action, targetId, ipAddress) {
|
|
2144
|
+
return new Promise((resolve) => {
|
|
2145
|
+
this.db.run(
|
|
2146
|
+
'INSERT INTO user_actions (user_id, action, target_id, ip_address) VALUES (?, ?, ?, ?)',
|
|
2147
|
+
[userId, action, targetId, ipAddress],
|
|
2148
|
+
() => resolve() // Don't fail if logging fails
|
|
2149
|
+
);
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
getStatus() {
|
|
2154
|
+
return {
|
|
2155
|
+
enabled: true,
|
|
2156
|
+
status: this.db ? 'Running' : 'Database not connected',
|
|
2157
|
+
taskProcessor: {
|
|
2158
|
+
running: this.taskProcessor !== null,
|
|
2159
|
+
processing: this.isProcessing,
|
|
2160
|
+
shutdownRequested: this.shutdownRequested
|
|
2161
|
+
}
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
module.exports = PublisherModule;
|