fhirsmith 0.18.2 → 1.0.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.
Files changed (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
@@ -14,6 +14,7 @@ const folders = require('../library/folder-setup');
14
14
  const escape = require('escape-html');
15
15
  const { requireSameOrigin } = require('../library/same-origin');
16
16
  const {Utilities} = require("../library/utilities");
17
+ const { guarded } = require('../library/sqlite-utilities');
17
18
 
18
19
  // GitHub refuses any file larger than this at the pre-receive hook, and the rejection takes down
19
20
  // the whole push rather than just the offending file.
@@ -206,19 +207,19 @@ class PublisherModule {
206
207
 
207
208
  for (const sql of tables) {
208
209
  await new Promise((resolve, reject) => {
209
- this.db.run(sql, (err) => {
210
+ this.db.run(sql, guarded(reject, (err) => {
210
211
  if (err) {
211
212
  this.logger.error('Failed to create table:', err);
212
213
  reject(err);
213
214
  } else {
214
215
  resolve();
215
216
  }
216
- });
217
+ }));
217
218
  });
218
219
  }
219
220
 
220
- // Create default admin user if none exists
221
- await this.createDefaultAdmin();
221
+ // the administrator, with the configured password (or a random one)
222
+ await this.ensureAdmin();
222
223
 
223
224
  // Schema migrations for existing databases
224
225
  await this.runMigrations();
@@ -227,100 +228,110 @@ class PublisherModule {
227
228
  async runMigrations() {
228
229
  // Add announcement column if it doesn't exist (added after initial schema)
229
230
  const columns = await new Promise((resolve, reject) => {
230
- this.db.all("PRAGMA table_info(tasks)", (err, rows) => {
231
+ this.db.all("PRAGMA table_info(tasks)", guarded(reject, (err, rows) => {
231
232
  if (err) reject(err);
232
233
  else resolve(rows || []);
233
- });
234
+ }));
234
235
  });
235
236
  const columnNames = columns.map(c => c.name);
236
237
  if (!columnNames.includes('announcement')) {
237
238
  await new Promise((resolve, reject) => {
238
- this.db.run('ALTER TABLE tasks ADD COLUMN announcement TEXT', (err) => {
239
+ this.db.run('ALTER TABLE tasks ADD COLUMN announcement TEXT', guarded(reject, (err) => {
239
240
  if (err) reject(err);
240
241
  else {
241
242
  this.logger.info('Migration: added announcement column to tasks table');
242
243
  resolve();
243
244
  }
244
- });
245
+ }));
245
246
  });
246
247
  }
247
248
  if (!columnNames.includes('publisher_version')) {
248
249
  await new Promise((resolve, reject) => {
249
- this.db.run('ALTER TABLE tasks ADD COLUMN publisher_version TEXT', (err) => {
250
+ this.db.run('ALTER TABLE tasks ADD COLUMN publisher_version TEXT', guarded(reject, (err) => {
250
251
  if (err) reject(err);
251
252
  else {
252
253
  this.logger.info('Migration: added publisher_version column to tasks table');
253
254
  resolve();
254
255
  }
255
- });
256
+ }));
256
257
  });
257
258
  }
258
259
  if (!columnNames.includes('release_url')) {
259
260
  await new Promise((resolve, reject) => {
260
- this.db.run('ALTER TABLE tasks ADD COLUMN release_url TEXT', (err) => {
261
+ this.db.run('ALTER TABLE tasks ADD COLUMN release_url TEXT', guarded(reject, (err) => {
261
262
  if (err) reject(err);
262
263
  else {
263
264
  this.logger.info('Migration: added release_url column to tasks table');
264
265
  resolve();
265
266
  }
266
- });
267
+ }));
267
268
  });
268
269
  }
269
270
  const websiteColumns = await new Promise((resolve, reject) => {
270
- this.db.all("PRAGMA table_info(websites)", (err, rows) => {
271
+ this.db.all("PRAGMA table_info(websites)", guarded(reject, (err, rows) => {
271
272
  if (err) reject(err);
272
273
  else resolve(rows || []);
273
- });
274
+ }));
274
275
  });
275
276
  const websiteColumnNames = websiteColumns.map(c => c.name);
276
277
  if (!websiteColumnNames.includes('git_root')) {
277
278
  await new Promise((resolve, reject) => {
278
- this.db.run('ALTER TABLE websites ADD COLUMN git_root TEXT', (err) => {
279
+ this.db.run('ALTER TABLE websites ADD COLUMN git_root TEXT', guarded(reject, (err) => {
279
280
  if (err) reject(err);
280
281
  else {
281
282
  this.logger.info('Migration: added git_root column to websites table');
282
283
  resolve();
283
284
  }
284
- });
285
+ }));
285
286
  });
286
287
  }
287
288
  }
288
289
 
289
- async createDefaultAdmin() {
290
- return new Promise((resolve, reject) => {
291
- this.db.get('SELECT COUNT(*) as count FROM users WHERE is_admin = 1', (err, row) => {
292
- if (err) {
293
- reject(err);
294
- return;
295
- }
296
-
297
- if (row.count === 0) {
298
- const defaultPassword = 'admin123'; // Change this!
299
- bcrypt.hash(defaultPassword, 10, (err, hash) => {
300
- if (err) {
301
- reject(err);
302
- return;
303
- }
290
+ /**
291
+ * The administrator, login 'admin'. Its password is the config's adminPassword, set (or reset)
292
+ * at every start, so changing the config changes it. With no adminPassword, an 'admin' that
293
+ * doesn't exist yet (and no other administrator) - or that still has the old fixed default
294
+ * password, admin123 - gets a random password (a UUID), which is logged: set adminPassword to
295
+ * choose it. There is no page to change a password
296
+ */
297
+ async ensureAdmin() {
298
+ const get = (sql, params = []) => new Promise((resolve, reject) =>
299
+ this.db.get(sql, params, guarded(reject, (err, row) => err ? reject(err) : resolve(row))));
300
+ const run = (sql, params = []) => new Promise((resolve, reject) =>
301
+ this.db.run(sql, params, guarded(reject, (err) => err ? reject(err) : resolve())));
302
+ const setPassword = async (admin, password) => {
303
+ const hash = await bcrypt.hash(password, 10);
304
+ if (admin) {
305
+ await run('UPDATE users SET password_hash = ?, is_admin = 1 WHERE id = ?', [hash, admin.id]);
306
+ } else {
307
+ await run('INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
308
+ ['Administrator', 'admin', hash, 1]);
309
+ }
310
+ };
304
311
 
305
- this.db.run(
306
- 'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
307
- ['Administrator', 'admin', hash, 1],
308
- (err) => {
309
- if (err) {
310
- this.logger.error('Failed to create default admin:', err);
311
- reject(err);
312
- } else {
313
- this.logger.warn('Created default admin user - login: admin, password: admin123 - CHANGE THIS!');
314
- resolve();
315
- }
316
- }
317
- );
318
- });
319
- } else {
320
- resolve();
321
- }
322
- });
323
- });
312
+ const configured = typeof this.config.adminPassword === 'string' && this.config.adminPassword.trim() !== ''
313
+ ? this.config.adminPassword : null;
314
+ const admin = await get("SELECT id, password_hash FROM users WHERE login = 'admin'");
315
+ if (configured) {
316
+ if (!admin || !(await bcrypt.compare(configured, admin.password_hash))) {
317
+ await setPassword(admin, configured);
318
+ this.logger.info(`Administrator 'admin': password ${admin ? 'changed to' : 'set from'} the configured adminPassword`);
319
+ }
320
+ return;
321
+ }
322
+ if (!admin) {
323
+ const others = await get('SELECT COUNT(*) as count FROM users WHERE is_admin = 1');
324
+ if (others.count > 0) {
325
+ return;
326
+ }
327
+ const password = crypto.randomUUID();
328
+ await setPassword(null, password);
329
+ this.logger.warn(`Created administrator - login: admin, password: ${password}. Set adminPassword in the publisher config to choose the password`);
330
+ } else if (await bcrypt.compare('admin123', admin.password_hash)) {
331
+ const password = crypto.randomUUID();
332
+ await setPassword(admin, password);
333
+ this.logger.warn(`Administrator 'admin' had the old default password (admin123); it is now ${password}. Set adminPassword in the publisher config to choose the password`);
334
+ }
324
335
  }
325
336
 
326
337
  setupRoutes() {
@@ -485,10 +496,10 @@ class PublisherModule {
485
496
  this.db.get(
486
497
  'SELECT * FROM tasks WHERE status = ? ORDER BY queued_at ASC LIMIT 1',
487
498
  ['queued'],
488
- (err, row) => {
499
+ guarded(reject, (err, row) => {
489
500
  if (err) reject(err);
490
501
  else resolve(row);
491
- }
502
+ })
492
503
  );
493
504
  });
494
505
  }
@@ -498,10 +509,10 @@ class PublisherModule {
498
509
  this.db.get(
499
510
  'SELECT * FROM tasks WHERE status = ? ORDER BY publishing_at ASC LIMIT 1',
500
511
  ['publishing'],
501
- (err, row) => {
512
+ guarded(reject, (err, row) => {
502
513
  if (err) reject(err);
503
514
  else resolve(row);
504
- }
515
+ })
505
516
  );
506
517
  });
507
518
  }
@@ -536,10 +547,10 @@ class PublisherModule {
536
547
  this.db.run(
537
548
  'UPDATE tasks SET ' + fields.join(', ') + ' WHERE id = ?',
538
549
  values,
539
- (err) => {
550
+ guarded(reject, (err) => {
540
551
  if (err) reject(err);
541
552
  else resolve();
542
- }
553
+ })
543
554
  );
544
555
  });
545
556
  }
@@ -558,20 +569,20 @@ class PublisherModule {
558
569
  this.db.run(
559
570
  'UPDATE tasks SET ' + assignments.join(', ') + ' WHERE id = ?',
560
571
  values,
561
- (err) => {
572
+ guarded(reject, (err) => {
562
573
  if (err) reject(err);
563
574
  else resolve();
564
- }
575
+ })
565
576
  );
566
577
  });
567
578
  }
568
579
 
569
580
  async logTaskMessage(taskId, level, message) {
570
- return new Promise((resolve) => {
581
+ return new Promise((resolve, reject) => {
571
582
  this.db.run(
572
583
  'INSERT INTO task_logs (task_id, level, message) VALUES (?, ?, ?)',
573
584
  [taskId.toString(), level, message],
574
- () => resolve() // Don't fail if logging fails
585
+ guarded(reject, () => resolve()) // Don't fail if logging fails
575
586
  );
576
587
  });
577
588
  }
@@ -1629,10 +1640,10 @@ class PublisherModule {
1629
1640
  this.db.get(
1630
1641
  'SELECT * FROM users WHERE login = ?',
1631
1642
  [login],
1632
- (err, row) => {
1643
+ guarded(reject, (err, row) => {
1633
1644
  if (err) reject(err);
1634
1645
  else resolve(row);
1635
- }
1646
+ })
1636
1647
  );
1637
1648
  });
1638
1649
 
@@ -1874,10 +1885,10 @@ class PublisherModule {
1874
1885
  this.db.run(
1875
1886
  'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
1876
1887
  [req.session.userId, website_id, github_org, github_repo, git_branch, npm_package_id, version],
1877
- function (err) {
1888
+ guarded(reject, function (err) {
1878
1889
  if (err) reject(err);
1879
1890
  else resolve(this.lastID);
1880
- }
1891
+ })
1881
1892
  );
1882
1893
  });
1883
1894
 
@@ -1924,10 +1935,10 @@ class PublisherModule {
1924
1935
  this.db.run(
1925
1936
  'UPDATE tasks SET status = ?, approved_by = ?, publishing_at = CURRENT_TIMESTAMP WHERE id = ?',
1926
1937
  ['publishing', req.session.userId, taskId],
1927
- (err) => {
1938
+ guarded(reject, (err) => {
1928
1939
  if (err) reject(err);
1929
1940
  else resolve();
1930
- }
1941
+ })
1931
1942
  );
1932
1943
  });
1933
1944
 
@@ -1985,18 +1996,18 @@ class PublisherModule {
1985
1996
 
1986
1997
  // Delete task logs
1987
1998
  await new Promise((resolve, reject) => {
1988
- this.db.run('DELETE FROM task_logs WHERE task_id = ?', [taskId.toString()], (err) => {
1999
+ this.db.run('DELETE FROM task_logs WHERE task_id = ?', [taskId.toString()], guarded(reject, (err) => {
1989
2000
  if (err) reject(err);
1990
2001
  else resolve();
1991
- });
2002
+ }));
1992
2003
  });
1993
2004
 
1994
2005
  // Delete task record
1995
2006
  await new Promise((resolve, reject) => {
1996
- this.db.run('DELETE FROM tasks WHERE id = ?', [taskId], (err) => {
2007
+ this.db.run('DELETE FROM tasks WHERE id = ?', [taskId], guarded(reject, (err) => {
1997
2008
  if (err) reject(err);
1998
2009
  else resolve();
1999
- });
2010
+ }));
2000
2011
  });
2001
2012
 
2002
2013
  this.logUserAction(req.session.userId, 'delete_task', taskId, req.ip);
@@ -2027,7 +2038,7 @@ class PublisherModule {
2027
2038
  this.db.run(
2028
2039
  'INSERT INTO tasks (user_id, website_id, github_org, github_repo, git_branch, npm_package_id, version) VALUES (?, ?, ?, ?, ?, ?, ?)',
2029
2040
  [req.session.userId, task.website_id, task.github_org, task.github_repo, task.git_branch, task.npm_package_id, task.version],
2030
- function (err) { if (err) reject(err); else resolve(this.lastID); }
2041
+ guarded(reject, function (err) { if (err) reject(err); else resolve(this.lastID); })
2031
2042
  );
2032
2043
  });
2033
2044
  this.logUserAction(req.session.userId, 'retry_task', newTaskId.toString(), req.ip);
@@ -2451,7 +2462,7 @@ class PublisherModule {
2451
2462
  this.db.run(
2452
2463
  'UPDATE websites SET name=?, local_folder=?, git_root = ?, history_templates=?, web_templates=?, server_update_script=?, is_active=? WHERE id=?',
2453
2464
  [name, local_folder, git_root, history_templates, web_templates, server_update_script, is_active === '1' ? 1 : 0, websiteId],
2454
- (err) => err ? reject(err) : resolve()
2465
+ guarded(reject, (err) => err ? reject(err) : resolve())
2455
2466
  );
2456
2467
  });
2457
2468
 
@@ -2572,10 +2583,10 @@ class PublisherModule {
2572
2583
  this.db.run(
2573
2584
  'INSERT INTO websites (name, local_folder, git_root, history_templates, web_templates, server_update_script) VALUES (?, ?, ?, ?, ?, ?)',
2574
2585
  [name, local_folder, git_root, history_templates, web_templates, server_update_script],
2575
- function (err) {
2586
+ guarded(reject, function (err) {
2576
2587
  if (err) reject(err);
2577
2588
  else resolve();
2578
- }
2589
+ })
2579
2590
  );
2580
2591
  });
2581
2592
 
@@ -2716,10 +2727,10 @@ class PublisherModule {
2716
2727
  this.db.run(
2717
2728
  'INSERT INTO users (name, login, password_hash, is_admin) VALUES (?, ?, ?, ?)',
2718
2729
  [name, login, passwordHash, is_admin ? 1 : 0],
2719
- function (err) {
2730
+ guarded(reject, function (err) {
2720
2731
  if (err) reject(err);
2721
2732
  else resolve();
2722
- }
2733
+ })
2723
2734
  );
2724
2735
  });
2725
2736
 
@@ -2750,10 +2761,10 @@ class PublisherModule {
2750
2761
 
2751
2762
  // Clear existing permissions
2752
2763
  await new Promise((resolve, reject) => {
2753
- this.db.run('DELETE FROM user_website_permissions WHERE user_id = ?', [user_id], (err) => {
2764
+ this.db.run('DELETE FROM user_website_permissions WHERE user_id = ?', [user_id], guarded(reject, (err) => {
2754
2765
  if (err) reject(err);
2755
2766
  else resolve();
2756
- });
2767
+ }));
2757
2768
  });
2758
2769
 
2759
2770
  // Add new permissions
@@ -2766,10 +2777,10 @@ class PublisherModule {
2766
2777
  this.db.run(
2767
2778
  'INSERT INTO user_website_permissions (user_id, website_id, can_queue, can_approve) VALUES (?, ?, ?, ?)',
2768
2779
  [user_id, website.id, canQueue ? 1 : 0, canApprove ? 1 : 0],
2769
- (err) => {
2780
+ guarded(reject, (err) => {
2770
2781
  if (err) reject(err);
2771
2782
  else resolve();
2772
- }
2783
+ })
2773
2784
  );
2774
2785
  });
2775
2786
  }
@@ -2795,10 +2806,10 @@ class PublisherModule {
2795
2806
  sql += ' LIMIT ' + limit;
2796
2807
  }
2797
2808
 
2798
- this.db.all(sql, (err, rows) => {
2809
+ this.db.all(sql, guarded(reject, (err, rows) => {
2799
2810
  if (err) reject(err);
2800
2811
  else resolve(rows || []);
2801
- });
2812
+ }));
2802
2813
  });
2803
2814
  }
2804
2815
 
@@ -2807,10 +2818,10 @@ class PublisherModule {
2807
2818
  this.db.get(
2808
2819
  '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 = ?',
2809
2820
  [taskId],
2810
- (err, row) => {
2821
+ guarded(reject, (err, row) => {
2811
2822
  if (err) reject(err);
2812
2823
  else resolve(row);
2813
- }
2824
+ })
2814
2825
  );
2815
2826
  });
2816
2827
  }
@@ -2820,10 +2831,10 @@ class PublisherModule {
2820
2831
  this.db.all(
2821
2832
  'SELECT * FROM task_logs WHERE task_id = ? ORDER BY timestamp ASC',
2822
2833
  [taskId.toString()],
2823
- (err, rows) => {
2834
+ guarded(reject, (err, rows) => {
2824
2835
  if (err) reject(err);
2825
2836
  else resolve(rows || []);
2826
- }
2837
+ })
2827
2838
  );
2828
2839
  });
2829
2840
  }
@@ -2833,10 +2844,10 @@ class PublisherModule {
2833
2844
  this.db.all(
2834
2845
  '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',
2835
2846
  [taskId.toString()],
2836
- (err, rows) => {
2847
+ guarded(reject, (err, rows) => {
2837
2848
  if (err) reject(err);
2838
2849
  else resolve(rows || []);
2839
- }
2850
+ })
2840
2851
  );
2841
2852
  });
2842
2853
  }
@@ -2846,10 +2857,10 @@ class PublisherModule {
2846
2857
  this.db.all(
2847
2858
  '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',
2848
2859
  [userId],
2849
- (err, rows) => {
2860
+ guarded(reject, (err, rows) => {
2850
2861
  if (err) reject(err);
2851
2862
  else resolve(rows || []);
2852
- }
2863
+ })
2853
2864
  );
2854
2865
  });
2855
2866
  }
@@ -2859,10 +2870,10 @@ class PublisherModule {
2859
2870
  this.db.get(
2860
2871
  'SELECT can_queue FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
2861
2872
  [userId, websiteId],
2862
- (err, row) => {
2873
+ guarded(reject, (err, row) => {
2863
2874
  if (err) reject(err);
2864
2875
  else resolve(row && row.can_queue);
2865
- }
2876
+ })
2866
2877
  );
2867
2878
  });
2868
2879
  }
@@ -2872,10 +2883,10 @@ class PublisherModule {
2872
2883
  this.db.get(
2873
2884
  'SELECT can_approve FROM user_website_permissions WHERE user_id = ? AND website_id = ?',
2874
2885
  [userId, websiteId],
2875
- (err, row) => {
2886
+ guarded(reject, (err, row) => {
2876
2887
  if (err) reject(err);
2877
2888
  else resolve(row && row.can_approve);
2878
- }
2889
+ })
2879
2890
  );
2880
2891
  });
2881
2892
  }
@@ -2885,10 +2896,10 @@ class PublisherModule {
2885
2896
  this.db.get(
2886
2897
  'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? AND status NOT IN (?, ?) ORDER BY queued_at DESC LIMIT 1',
2887
2898
  [packageId, version, 'complete', 'failed'],
2888
- (err, row) => {
2899
+ guarded(reject, (err, row) => {
2889
2900
  if (err) reject(err);
2890
2901
  else resolve(row);
2891
- }
2902
+ })
2892
2903
  );
2893
2904
  });
2894
2905
  }
@@ -2898,29 +2909,29 @@ class PublisherModule {
2898
2909
  this.db.get(
2899
2910
  'SELECT * FROM tasks WHERE npm_package_id = ? AND version = ? ORDER BY queued_at DESC LIMIT 1',
2900
2911
  [packageId, version],
2901
- (err, row) => {
2912
+ guarded(reject, (err, row) => {
2902
2913
  if (err) reject(err);
2903
2914
  else resolve(row);
2904
- }
2915
+ })
2905
2916
  );
2906
2917
  });
2907
2918
  }
2908
2919
 
2909
2920
  async getWebsites() {
2910
2921
  return new Promise((resolve, reject) => {
2911
- this.db.all('SELECT * FROM websites ORDER BY name', (err, rows) => {
2922
+ this.db.all('SELECT * FROM websites ORDER BY name', guarded(reject, (err, rows) => {
2912
2923
  if (err) reject(err);
2913
2924
  else resolve(rows || []);
2914
- });
2925
+ }));
2915
2926
  });
2916
2927
  }
2917
2928
 
2918
2929
  async getWebsite(websiteId) {
2919
2930
  return new Promise((resolve, reject) => {
2920
- this.db.get('SELECT * FROM websites WHERE id = ?', [websiteId], (err, row) => {
2931
+ this.db.get('SELECT * FROM websites WHERE id = ?', [websiteId], guarded(reject, (err, row) => {
2921
2932
  if (err) reject(err);
2922
2933
  else resolve(row);
2923
- });
2934
+ }));
2924
2935
  });
2925
2936
  }
2926
2937
 
@@ -2967,10 +2978,10 @@ class PublisherModule {
2967
2978
 
2968
2979
  async getUsers() {
2969
2980
  return new Promise((resolve, reject) => {
2970
- this.db.all('SELECT id, name, login, is_admin, created_at FROM users ORDER BY name', (err, rows) => {
2981
+ this.db.all('SELECT id, name, login, is_admin, created_at FROM users ORDER BY name', guarded(reject, (err, rows) => {
2971
2982
  if (err) reject(err);
2972
2983
  else resolve(rows || []);
2973
- });
2984
+ }));
2974
2985
  });
2975
2986
  }
2976
2987
 
@@ -2979,10 +2990,10 @@ class PublisherModule {
2979
2990
  this.db.all(
2980
2991
  'SELECT * FROM user_website_permissions WHERE user_id = ?',
2981
2992
  [userId],
2982
- (err, rows) => {
2993
+ guarded(reject, (err, rows) => {
2983
2994
  if (err) reject(err);
2984
2995
  else resolve(rows || []);
2985
- }
2996
+ })
2986
2997
  );
2987
2998
  });
2988
2999
  }
@@ -2997,7 +3008,7 @@ class PublisherModule {
2997
3008
  return 'The database is not open.';
2998
3009
  }
2999
3010
  const get = (sql, params) => new Promise((resolve, reject) =>
3000
- this.db.get(sql, params, (err, row) => err ? reject(err) : resolve(row)));
3011
+ this.db.get(sql, params, guarded(reject, (err, row) => err ? reject(err) : resolve(row))));
3001
3012
  // the timestamps are CURRENT_TIMESTAMP: UTC, 'YYYY-MM-DD HH:MM:SS'
3002
3013
  const utc = ms => new Date(ms).toISOString().replace('T', ' ').substring(0, 19);
3003
3014
  try {
@@ -3039,15 +3050,15 @@ class PublisherModule {
3039
3050
 
3040
3051
  // Close database
3041
3052
  if (this.db) {
3042
- return new Promise((resolve) => {
3043
- this.db.close((err) => {
3053
+ return new Promise((resolve, reject) => {
3054
+ this.db.close(guarded(reject, (err) => {
3044
3055
  if (err) {
3045
3056
  this.logger.error('Error closing database:', err);
3046
3057
  } else {
3047
3058
  this.logger.info('Database closed');
3048
3059
  }
3049
3060
  resolve();
3050
- });
3061
+ }));
3051
3062
  });
3052
3063
  }
3053
3064
  }
@@ -0,0 +1,75 @@
1
+ # Publisher - publishing FHIR implementation guides
2
+
3
+ The `publisher` module runs IG publications for the websites it is set up for: a user asks for
4
+ a publication of an IG (a GitHub repository, branch, package id and version), the module builds
5
+ it with the IG Publisher, and once an approver has looked at the draft, it publishes it into the
6
+ website's web folder and pushes it.
7
+
8
+ This module exists to run the publication service at healthintersections.com.au/publisher; it
9
+ isn't designed for anyone else to run, though nothing stops you. It needs Java, git, SUSHI (npm)
10
+ and a checkout of each website's web folder on the server, and push access to them.
11
+
12
+ ## How a publication runs
13
+
14
+ 1. **Request**: a logged-in user who may publish to the website creates a task: the website,
15
+ the GitHub org, repository and branch, and the package id and version it should produce.
16
+ The task is `queued`.
17
+ 2. **Draft build** (`building`): the module clones the repository into a task folder under
18
+ `workspaceRoot`, downloads the latest IG Publisher, updates SUSHI, runs the IG Publisher,
19
+ and checks that the package id and version are the ones asked for. Then the task is
20
+ `waiting for approval`, with its output, history and QA report on the task's pages.
21
+ 3. **Approval**: a user who may approve for the website approves it (or deletes it).
22
+ 4. **Publication** (`publishing`): the module clones the supporting repositories (the history
23
+ and web templates, and the IG registry), pulls the web folder, runs the IG Publisher in
24
+ go-publish mode, checks the result is a publication build, moves files too big for GitHub
25
+ out of the web folder (`large-file-archive`), commits and pushes the web folder, then the
26
+ IG registry. With a `github-app`, it then tags the IG source repository and makes a GitHub
27
+ release with a zip of the source - a failure there is only a warning.
28
+
29
+ A task that fails can be retried. Only one task runs at a time.
30
+
31
+ ## Configuration
32
+
33
+ In `config.json`, under `modules`:
34
+
35
+ ```json
36
+ "publisher": {
37
+ "enabled": true,
38
+ "database": "publisher.db",
39
+ "sessionSecret": "a-long-random-string",
40
+ "adminPassword": "a-good-password",
41
+ "workspaceRoot": "workspaces"
42
+ }
43
+ ```
44
+
45
+ | Setting | Default | Description |
46
+ |---|---|---|
47
+ | `enabled` | false | Run the module, at `/publisher` |
48
+ | `database` | - | The SQLite database (users, websites, permissions, tasks). A relative path is in the data directory's `publisher` folder |
49
+ | `sessionSecret` | random | Signs the login session cookie. Set it: without it, a random one is made at startup (with a warning), and logins don't survive a restart |
50
+ | `adminPassword` | random | The password of the administrator, `admin` (see [Administration](#administration)) |
51
+ | `cookieSecure` | true | The session cookie is only sent over https. Set `false` only to run over plain http. Behind a TLS proxy, the proxy must set `X-Forwarded-Proto`, or logins seem to do nothing |
52
+ | `workspaceRoot` | - | Where task folders (`task-{id}`) are made. A relative path is in the data directory's `publisher` folder |
53
+ | `igPublisherTimeoutMinutes` | 60 | An IG Publisher run that takes longer is stopped, and the task fails |
54
+ | `pollInterval` | 5000 | How often (ms) the module looks for a task to run |
55
+ | `loginAttemptsPerWindow` | 20 | Failed logins allowed per IP address per 15 minutes |
56
+ | `taskActionsPerMinute` | 30 | Task actions (create, approve, delete, retry) per IP address per minute |
57
+ | `taskReadsPerMinute` | 300 | Reads of task output, history and QA pages per IP address per minute |
58
+ | `large-file-archive` | none | Where files over GitHub's 100 MB limit are moved from the web folder, for websites whose publish setup says they are on GitHub. A relative path is in the data directory's `publisher` folder. Without it they are left in place, and the push fails |
59
+ | `ig-registry-url` | `git@github.com:FHIR/ig-registry.git` | Where the IG registry is cloned from and pushed to. Set it to an ssh_config Host alias to push with a repository deploy key |
60
+ | `github-app` | none | `app-id`, `private-key` (a .pem file) and `installations` (GitHub org -> installation id) for the GitHub App that tags source repositories and makes releases. Without it, that step is skipped |
61
+
62
+ `config-template.json` has the same settings, with more on each.
63
+
64
+ ## Administration
65
+
66
+ The administrator's login is `admin`, and its password is `adminPassword` from the config,
67
+ set at every start - so to change it, change the config and restart. Without `adminPassword`,
68
+ the first start gives the administrator a random password (a UUID) and logs it; set
69
+ `adminPassword` to choose your own. There is no page to change a password.
70
+
71
+ Administrators manage (at `/publisher/admin/...`):
72
+
73
+ - **Websites**: each has a name, its local web folder (a git checkout), the history and web
74
+ templates, and a server update script.
75
+ - **Users**, and what each may do for each website: publish (create tasks) and approve.