fhirsmith 0.13.4 → 0.14.1

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 (52) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +16 -3
  3. package/config-template.json +81 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/library/languages.js +15 -0
  7. package/library/same-origin.js +62 -0
  8. package/nginx.md +399 -0
  9. package/package.json +3 -2
  10. package/publisher/git-remote.js +141 -0
  11. package/publisher/github-release.js +195 -0
  12. package/publisher/publisher-template.html +1 -0
  13. package/publisher/publisher.js +241 -56
  14. package/registry/registry.js +3 -1
  15. package/server.js +57 -15
  16. package/stats.js +19 -13
  17. package/testing/readme.md +144 -0
  18. package/testing/render.js +723 -0
  19. package/testing/search.js +378 -0
  20. package/testing/store.js +410 -0
  21. package/testing/testing-template.html +126 -0
  22. package/testing/testing.js +760 -0
  23. package/translations/Messages.properties +32 -0
  24. package/tx/closure/closure-store.js +396 -0
  25. package/tx/cs/cs-api.js +6 -1
  26. package/tx/cs/cs-rxnorm.js +35 -8
  27. package/tx/cs/cs-snomed.js +19 -69
  28. package/tx/html/codesystem-operations.liquid +177 -0
  29. package/tx/html/ecl-panel.liquid +93 -0
  30. package/tx/html/home-metrics.liquid +39 -76
  31. package/tx/html/tx-template.html +1 -0
  32. package/tx/library/conceptmap.js +22 -6
  33. package/tx/library/designations.js +12 -3
  34. package/tx/library/operation-outcome.js +3 -1
  35. package/tx/library/renderer.js +42 -5
  36. package/tx/operation-context.js +80 -17
  37. package/tx/provider.js +85 -0
  38. package/tx/sct/editions.js +70 -0
  39. package/tx/sct/expressions.js +128 -44
  40. package/tx/tx-html.js +426 -44
  41. package/tx/tx.js +117 -9
  42. package/tx/workers/cache-control.js +2 -2
  43. package/tx/workers/closure.js +483 -13
  44. package/tx/workers/expand.js +2 -2
  45. package/tx/workers/metadata.js +8 -3
  46. package/tx/workers/read.js +18 -3
  47. package/tx/workers/subsumes.js +18 -2
  48. package/tx/workers/translate.js +192 -21
  49. package/tx/workers/validate.js +70 -187
  50. package/tx/workers/worker.js +12 -35
  51. package/tx/xversion/xv-conceptmap.js +17 -8
  52. package/utilities/submit-test-report.js +150 -0
@@ -2,13 +2,17 @@ const express = require('express');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
4
  const validation = require('./validation');
5
+ const gitRemote = require('./git-remote');
6
+ const githubRelease = require('./github-release');
5
7
  const Database = require('sqlite3').Database;
6
8
  const bcrypt = require('bcrypt');
7
9
  const session = require('express-session');
10
+ const lusca = require('lusca');
8
11
  const rateLimit = require('express-rate-limit');
9
12
  const crypto = require('crypto');
10
13
  const folders = require('../library/folder-setup');
11
14
  const escape = require('escape-html');
15
+ const { requireSameOrigin } = require('../library/same-origin');
12
16
  const {Utilities} = require("../library/utilities");
13
17
 
14
18
  // GitHub refuses any file larger than this at the pre-receive hook, and the rejection takes down
@@ -57,14 +61,29 @@ class PublisherModule {
57
61
  // plain HTTP - without it the browser will not send the cookie back and login
58
62
  // appears to succeed and then silently do nothing.
59
63
  secure: this.config.cookieSecure ?? true,
64
+ // CSRF: the browser leaves the cookie off a POST that starts on another site. That,
65
+ // with the Origin check below, is what stops another site's page from driving a
66
+ // logged-in browser to create, approve or delete tasks, or to add users
67
+ sameSite: 'lax',
68
+ httpOnly: true,
60
69
  maxAge: 24 * 60 * 60 * 1000 // 24 hours
61
70
  }
62
71
  // Not using SQLiteStore to avoid the database conflict
63
72
  }));
64
73
 
74
+ // every POST must come from one of our own pages - see library/same-origin.js
75
+ this.router.use(requireSameOrigin());
76
+
65
77
  // Parse form data
66
78
  this.router.use(express.urlencoded({ extended: true }));
67
79
 
80
+ // CSRF tokens (lusca): every form carries the session's token in a hidden _csrf field,
81
+ // and every POST route has this.csrf in it, written out so static analysis can see it.
82
+ // Pages only make a token for someone logged in (making one stores a secret in the
83
+ // session, and anonymous visitors should not get sessions); the login page always does
84
+ this.csrf = lusca.csrf({ key: '_csrf' });
85
+ this.csrfIfLoggedIn = (req, res, next) => req.session && req.session.userId ? this.csrf(req, res, next) : next();
86
+
68
87
  // Set up routes
69
88
  this.setupRoutes();
70
89
 
@@ -150,6 +169,7 @@ class PublisherModule {
150
169
  build_output_path TEXT,
151
170
  failure_reason TEXT,
152
171
  announcement TEXT,
172
+ release_url TEXT,
153
173
  approved_by INTEGER,
154
174
  queued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
155
175
  building_at DATETIME,
@@ -235,6 +255,17 @@ class PublisherModule {
235
255
  });
236
256
  });
237
257
  }
258
+ if (!columnNames.includes('release_url')) {
259
+ await new Promise((resolve, reject) => {
260
+ this.db.run('ALTER TABLE tasks ADD COLUMN release_url TEXT', (err) => {
261
+ if (err) reject(err);
262
+ else {
263
+ this.logger.info('Migration: added release_url column to tasks table');
264
+ resolve();
265
+ }
266
+ });
267
+ });
268
+ }
238
269
  const websiteColumns = await new Promise((resolve, reject) => {
239
270
  this.db.all("PRAGMA table_info(websites)", (err, rows) => {
240
271
  if (err) reject(err);
@@ -294,7 +325,7 @@ class PublisherModule {
294
325
 
295
326
  setupRoutes() {
296
327
  // Main dashboard
297
- this.router.get('/', this.renderDashboard.bind(this));
328
+ this.router.get('/', this.csrfIfLoggedIn, this.renderDashboard.bind(this));
298
329
 
299
330
  // Authentication. The login post is rate limited: besides slowing down guessing, it
300
331
  // is the one unauthenticated route that runs bcrypt, and bcrypt.compare holds a libuv
@@ -308,20 +339,39 @@ class PublisherModule {
308
339
  legacyHeaders: false
309
340
  });
310
341
 
311
- this.router.get('/login', this.renderLogin.bind(this));
312
- this.router.post('/login', loginLimiter, this.handleLogin.bind(this));
313
- this.router.post('/logout', this.handleLogout.bind(this));
342
+ // Task actions delete build output from disk or queue new builds: a logged-in user can't
343
+ // be allowed to hammer them either
344
+ const taskActionLimiter = rateLimit({
345
+ windowMs: 60 * 1000,
346
+ max: this.config.taskActionsPerMinute ?? 30,
347
+ message: 'Too many task actions, please try again later.',
348
+ standardHeaders: true,
349
+ legacyHeaders: false
350
+ });
351
+
352
+ // the task pages read build logs and QA output from disk
353
+ const taskReadLimiter = rateLimit({
354
+ windowMs: 60 * 1000,
355
+ max: this.config.taskReadsPerMinute ?? 300,
356
+ message: 'Too many requests, please try again later.',
357
+ standardHeaders: true,
358
+ legacyHeaders: false
359
+ });
360
+
361
+ this.router.get('/login', this.csrf, this.renderLogin.bind(this));
362
+ this.router.post('/login', loginLimiter, this.csrf, this.handleLogin.bind(this));
363
+ this.router.post('/logout', this.csrf, this.handleLogout.bind(this));
314
364
 
315
365
  // Tasks
316
- this.router.get('/tasks', this.renderTasks.bind(this));
317
- this.router.post('/tasks', this.requireAuth.bind(this), this.createTask.bind(this));
318
- this.router.post('/tasks/:id/approve', this.requireAuth.bind(this), this.approveTask.bind(this));
319
- this.router.post('/tasks/:id/delete', this.requireAuth.bind(this), this.deleteTask.bind(this));
320
- this.router.post('/tasks/:id/retry', this.requireAuth.bind(this), this.retryTask.bind(this));
321
- this.router.get('/tasks/:id/output', this.getTaskOutput.bind(this));
322
- this.router.get('/tasks/:id/history', this.getTaskHistory.bind(this));
323
- this.router.get('/tasks/:id/qa', this.getTaskQA.bind(this));
324
- this.router.use('/tasks/:id/qa-files', (req, res, next) => {
366
+ this.router.get('/tasks', this.csrfIfLoggedIn, this.renderTasks.bind(this));
367
+ this.router.post('/tasks', taskActionLimiter, this.csrf, this.requireAuth.bind(this), this.createTask.bind(this));
368
+ this.router.post('/tasks/:id/approve', taskActionLimiter, this.csrf, this.requireAuth.bind(this), this.approveTask.bind(this));
369
+ this.router.post('/tasks/:id/delete', taskActionLimiter, this.csrf, this.requireAuth.bind(this), this.deleteTask.bind(this));
370
+ this.router.post('/tasks/:id/retry', taskActionLimiter, this.csrf, this.requireAuth.bind(this), this.retryTask.bind(this));
371
+ this.router.get('/tasks/:id/output', taskReadLimiter, this.csrfIfLoggedIn, this.getTaskOutput.bind(this));
372
+ this.router.get('/tasks/:id/history', taskReadLimiter, this.csrfIfLoggedIn, this.getTaskHistory.bind(this));
373
+ this.router.get('/tasks/:id/qa', taskReadLimiter, this.csrfIfLoggedIn, this.getTaskQA.bind(this));
374
+ this.router.use('/tasks/:id/qa-files', taskReadLimiter, (req, res, next) => {
325
375
  const taskId = req.params.id;
326
376
  this.getTask(taskId).then(task => {
327
377
  if (!task || !task.local_folder) {
@@ -333,13 +383,27 @@ class PublisherModule {
333
383
  });
334
384
 
335
385
  // Admin routes
336
- this.router.get('/admin/websites', this.requireAdmin.bind(this), this.renderWebsites.bind(this));
337
- this.router.post('/admin/websites', this.requireAdmin.bind(this), this.createWebsite.bind(this));
338
- this.router.get('/admin/websites/:id/edit', this.requireAdmin.bind(this), this.renderEditWebsite.bind(this));
339
- this.router.post('/admin/websites/:id/edit', this.requireAdmin.bind(this), this.updateWebsite.bind(this));
340
- this.router.get('/admin/users', this.requireAdmin.bind(this), this.renderUsers.bind(this));
341
- this.router.post('/admin/users', this.requireAdmin.bind(this), this.createUser.bind(this));
342
- this.router.post('/admin/permissions', this.requireAdmin.bind(this), this.updatePermissions.bind(this));
386
+ this.router.get('/admin/websites', this.csrf, this.requireAdmin.bind(this), this.renderWebsites.bind(this));
387
+ this.router.post('/admin/websites', this.csrf, this.requireAdmin.bind(this), this.createWebsite.bind(this));
388
+ this.router.get('/admin/websites/:id/edit', this.csrf, this.requireAdmin.bind(this), this.renderEditWebsite.bind(this));
389
+ this.router.post('/admin/websites/:id/edit', this.csrf, this.requireAdmin.bind(this), this.updateWebsite.bind(this));
390
+ this.router.get('/admin/users', this.csrf, this.requireAdmin.bind(this), this.renderUsers.bind(this));
391
+ this.router.post('/admin/users', this.csrf, this.requireAdmin.bind(this), this.createUser.bind(this));
392
+ this.router.post('/admin/permissions', this.csrf, this.requireAdmin.bind(this), this.updatePermissions.bind(this));
393
+
394
+ // lusca rejects a post with a missing or wrong token by passing on an error
395
+ this.router.use((err, req, res, next) => {
396
+ if (err && typeof err.message === 'string' && err.message.startsWith('CSRF token')) {
397
+ return res.status(403).type('text/plain').send('The form has expired or did not come from this site - go back, reload the page and try again');
398
+ }
399
+ next(err);
400
+ });
401
+ }
402
+
403
+ /** The hidden field every POST form carries: the session's CSRF token. */
404
+ csrfField(res) {
405
+ const token = res.locals && res.locals._csrf;
406
+ return token ? '<input type="hidden" name="_csrf" value="' + escape(token) + '">' : '';
343
407
  }
344
408
 
345
409
  // Background Task Processing
@@ -722,7 +786,7 @@ class PublisherModule {
722
786
 
723
787
  async cloneRepository(task, draftDir) {
724
788
  const { spawn } = require('child_process');
725
- const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
789
+ const gitUrl = gitRemote.githubUrl(task.github_org, task.github_repo);
726
790
 
727
791
  await this.logTaskMessage(task.id, 'info', 'Cloning repository: ' + gitUrl + ' (branch: ' + task.git_branch + ')');
728
792
 
@@ -734,7 +798,10 @@ class PublisherModule {
734
798
  gitUrl,
735
799
  draftDir
736
800
  ], {
737
- stdio: ['pipe', 'pipe', 'pipe']
801
+ stdio: ['pipe', 'pipe', 'pipe'],
802
+ // no terminal prompt: a missing repo looks like a private one to GitHub, and
803
+ // git would otherwise ask for a username (see git-remote.js)
804
+ env: gitRemote.gitEnv()
738
805
  });
739
806
 
740
807
  let stderr = '';
@@ -748,7 +815,10 @@ class PublisherModule {
748
815
  await this.logTaskMessage(task.id, 'info', 'Repository cloned successfully');
749
816
  resolve();
750
817
  } else {
751
- const error = 'Git clone failed with code ' + code + ': ' + stderr;
818
+ const explained = gitRemote.explainCloneFailure(stderr, task.github_org, task.github_repo, task.git_branch);
819
+ const error = explained
820
+ ? 'Git clone failed: ' + explained
821
+ : 'Git clone failed with code ' + code + ': ' + stderr;
752
822
  await this.logTaskMessage(task.id, 'error', error);
753
823
  reject(new Error(error));
754
824
  }
@@ -868,6 +938,28 @@ class PublisherModule {
868
938
  errors.push('Version mismatch: task specifies "' + task.version + '" but build produced "' + qaData['ig-ver'] + '"');
869
939
  }
870
940
 
941
+ // publication-request.json is what the publication run actually follows - it decides the
942
+ // version folder the IG is published into. If it wasn't updated along with the IG, the run
943
+ // publishes the new content over the previous release's folder, so catch it here, before the
944
+ // task can be approved.
945
+ const prPath = path.join(draftDir, 'publication-request.json');
946
+ if (fs.existsSync(prPath)) {
947
+ let pr = null;
948
+ try {
949
+ pr = JSON.parse(fs.readFileSync(prPath, 'utf8'));
950
+ } catch (e) {
951
+ errors.push('publication-request.json could not be parsed: ' + e.message);
952
+ }
953
+ if (pr) {
954
+ if (pr['package-id'] !== task.npm_package_id) {
955
+ errors.push('publication-request.json package-id is "' + pr['package-id'] + '" but the task specifies "' + task.npm_package_id + '"');
956
+ }
957
+ if (pr.version !== task.version) {
958
+ errors.push('publication-request.json version is "' + pr.version + '" but the task specifies "' + task.version + '" - update publication-request.json in the IG');
959
+ }
960
+ }
961
+ }
962
+
871
963
  if (errors.length > 0) {
872
964
  for (const err of errors) {
873
965
  await this.logTaskMessage(task.id, 'error', err);
@@ -950,10 +1042,14 @@ class PublisherModule {
950
1042
  if (typeof json.url === 'string' && json.url.startsWith('file:')) {
951
1043
  problems.push('url is a local file path (' + json.url + ')');
952
1044
  }
1045
+ if (json.version !== task.version) {
1046
+ problems.push('its version is ' + json.version + ', not ' + task.version);
1047
+ }
953
1048
  if (problems.length > 0) {
954
- throw new Error('Published package ' + pkgPath + ' is a draft build, not a publication build (' +
955
- problems.join('; ') + '). The IG Publisher publication run likely skipped package ' +
956
- 'regeneration (e.g. a Jekyll/template failure). Not committing.');
1049
+ throw new Error('Published package ' + pkgPath + ' is not the publication build of ' +
1050
+ task.npm_package_id + '#' + task.version + ' (' + problems.join('; ') + '). ' +
1051
+ 'Either the IG Publisher publication run skipped package regeneration (e.g. a Jekyll/template ' +
1052
+ 'failure), or publication-request.json points at another version. Not committing.');
957
1053
  }
958
1054
  await this.logTaskMessage(task.id, 'info', 'Verified publication package: ' + pkgPath);
959
1055
  }
@@ -1203,15 +1299,24 @@ class PublisherModule {
1203
1299
  // Step 7: Commit and push the ig-registry
1204
1300
  await this.logTaskMessage(task.id, 'info', 'Committing changes to ig-registry...');
1205
1301
  const registryCommitMsg = 'publish ' + task.npm_package_id + '#' + task.version;
1206
- await this.runCommand('git', ['commit', '-a', '-m', registryCommitMsg], { cwd: registryDir }, task.id, 'Committing ig-registry changes');
1207
- await this.runCommand('git', ['pull'], { cwd: registryDir }, task.id, 'Pulling latest ig-registry');
1208
- await this.runCommand('git', ['push'], { cwd: registryDir }, task.id, 'Pushing ig-registry changes');
1302
+ // A republication of a version the registry already lists (a technical correction) leaves the
1303
+ // registry as it was, and 'git commit' fails on a clean tree - which is not an error here.
1304
+ const registryStatus = await this.runCommand('git', ['status', '--porcelain', '--untracked-files=no'],
1305
+ { cwd: registryDir }, task.id, 'Checking for ig-registry changes');
1306
+ if (registryStatus.trim() === '') {
1307
+ await this.logTaskMessage(task.id, 'warn', 'The publication run made no changes to the ig-registry - nothing to commit');
1308
+ } else {
1309
+ await this.runCommand('git', ['commit', '-a', '-m', registryCommitMsg], { cwd: registryDir }, task.id, 'Committing ig-registry changes');
1310
+ await this.runCommand('git', ['pull'], { cwd: registryDir }, task.id, 'Pulling latest ig-registry');
1311
+ await this.runCommand('git', ['push'], { cwd: registryDir }, task.id, 'Pushing ig-registry changes');
1312
+ }
1209
1313
 
1210
1314
  // Step 8: Read the announcement text and store it in the database
1315
+ let announcement = null;
1211
1316
  const announcementPath = path.join(zipsDir, task.npm_package_id + '#' + task.version + '-announcement.txt');
1212
1317
  if (fs.existsSync(announcementPath)) {
1213
1318
  try {
1214
- const announcement = fs.readFileSync(announcementPath, 'utf8');
1319
+ announcement = fs.readFileSync(announcementPath, 'utf8');
1215
1320
  await this.updateTaskFields(task.id, { announcement: announcement });
1216
1321
  await this.logTaskMessage(task.id, 'info', 'Announcement text saved (' + announcement.length + ' chars)');
1217
1322
  } catch (err) {
@@ -1221,6 +1326,9 @@ class PublisherModule {
1221
1326
  await this.logTaskMessage(task.id, 'warn', 'No announcement file found at ' + announcementPath);
1222
1327
  }
1223
1328
 
1329
+ // Step 8b: Tag the source repository and publish a GitHub release carrying its source zip
1330
+ await this.createSourceRelease(task, draftDir, zipsDir, announcement);
1331
+
1224
1332
  // Step 9: Run the website update script
1225
1333
  if (website.server_update_script) {
1226
1334
  await this.logTaskMessage(task.id, 'info', 'Running website update script: ' + website.server_update_script);
@@ -1228,6 +1336,60 @@ class PublisherModule {
1228
1336
  }
1229
1337
  }
1230
1338
 
1339
+ // Tag the IG source repository and publish a GitHub release with a zip of the source at the
1340
+ // commit that was published. Runs only when a GitHub App is configured; without one there is no
1341
+ // credential that can write to the source repository, and the step is skipped.
1342
+ //
1343
+ // Nothing here is allowed to fail the task. By the time this runs the IG is published, the web
1344
+ // folder is pushed and the registry is updated - a release that did not get made is worth a
1345
+ // warning in the log and nothing more.
1346
+ async createSourceRelease(task, draftDir, zipsDir, announcement) {
1347
+ const appConfig = this.config && this.config['github-app'];
1348
+ if (!githubRelease.isConfigured(appConfig)) {
1349
+ await this.logTaskMessage(task.id, 'info',
1350
+ 'No GitHub App configured - skipping the source tag and release');
1351
+ return;
1352
+ }
1353
+
1354
+ try {
1355
+ // The draft clone is the exact tree that was published, so its HEAD is the commit the
1356
+ // release should point at.
1357
+ const sha = (await this.runCommand('git', ['rev-parse', 'HEAD'], { cwd: draftDir },
1358
+ task.id, 'Reading the published commit')).trim();
1359
+
1360
+ // git archive takes the committed state, not the working directory: by now the publisher has
1361
+ // built in this folder twice and left output/, temp/, template/ and fsh-generated/ behind,
1362
+ // none of which are in the archive. It is also exactly reproducible from the tag.
1363
+ const stem = task.npm_package_id + '-' + task.version;
1364
+ const assetName = stem + '-source.zip';
1365
+ const zipPath = path.join(zipsDir, assetName);
1366
+ await this.runCommand('git',
1367
+ ['archive', '--format=zip', '--prefix=' + stem + '/', '-o', zipPath, 'HEAD'],
1368
+ { cwd: draftDir }, task.id, 'Building the source zip');
1369
+
1370
+ const result = await githubRelease.publishRelease({
1371
+ config: appConfig,
1372
+ org: task.github_org,
1373
+ repo: task.github_repo,
1374
+ tag: 'v' + task.version,
1375
+ sha: sha,
1376
+ releaseName: task.npm_package_id + '#' + task.version,
1377
+ body: announcement || '',
1378
+ zipPath: zipPath,
1379
+ assetName: assetName,
1380
+ // logTaskMessage never rejects, and github-release logs without waiting
1381
+ log: (level, message) => { void this.logTaskMessage(task.id, level, message); }
1382
+ });
1383
+
1384
+ await this.updateTaskFields(task.id, { release_url: result.url });
1385
+ } catch (err) {
1386
+ const detail = (err.response && err.response.data && err.response.data.message) || err.message;
1387
+ await this.logTaskMessage(task.id, 'warn',
1388
+ 'Could not tag and release the source repository: ' + detail +
1389
+ '. The IG itself is published - this does not affect it.');
1390
+ }
1391
+ }
1392
+
1231
1393
  async runPublisherGoPublish(taskId, publisherJar, sourceDir, webDir, registryDir, historyDir, templatesDir, zipsDir, logFile) {
1232
1394
  const { spawn } = require('child_process');
1233
1395
 
@@ -1364,7 +1526,7 @@ class PublisherModule {
1364
1526
  content += '<a href="/publisher/admin/websites" class="btn btn-secondary me-2">Manage Websites</a>';
1365
1527
  content += '<a href="/publisher/admin/users" class="btn btn-secondary">Manage Users</a>';
1366
1528
  }
1367
- content += '<form style="display: inline-block; margin-left: 10px;" method="post" action="/publisher/logout">';
1529
+ content += '<form style="display: inline-block; margin-left: 10px;" method="post" action="/publisher/logout">' + this.csrfField(res);
1368
1530
  content += '<button type="submit" class="btn btn-outline-secondary">' + escape(req.session.userName) + ' \u2014 Logout</button>';
1369
1531
  content += '</form>';
1370
1532
  content += '</div>';
@@ -1405,7 +1567,7 @@ class PublisherModule {
1405
1567
  templateVars: {
1406
1568
  loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
1407
1569
  loginPath: req.session.userId ? "logout" : 'login',
1408
- loginAction: req.session.userId ? "POST" : 'GET'
1570
+ loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
1409
1571
  }
1410
1572
  });
1411
1573
 
@@ -1429,7 +1591,7 @@ class PublisherModule {
1429
1591
  let content = '<div class="row justify-content-center">';
1430
1592
  content += '<div class="col-md-6">';
1431
1593
  content += '<h3>Login</h3>';
1432
- content += '<form method="post" action="/publisher/login">';
1594
+ content += '<form method="post" action="/publisher/login">' + this.csrfField(res);
1433
1595
  content += '<div class="mb-3">';
1434
1596
  content += '<label for="login" class="form-label">Username</label>';
1435
1597
  content += '<input type="text" class="form-control" id="login" name="login" required>';
@@ -1447,7 +1609,7 @@ class PublisherModule {
1447
1609
  templateVars: {
1448
1610
  loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
1449
1611
  loginPath: req.session.userId ? "logout" : 'login',
1450
- loginAction: req.session.userId ? "POST" : 'GET'
1612
+ loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
1451
1613
  }});
1452
1614
  res.setHeader('Content-Type', 'text/html');
1453
1615
  res.send(html);
@@ -1483,6 +1645,9 @@ class PublisherModule {
1483
1645
  return res.redirect('/publisher/login?error=invalid');
1484
1646
  }
1485
1647
 
1648
+ // a new session id on login, so an id planted in the browser before login
1649
+ // (session fixation) does not become a logged-in session
1650
+ await new Promise((resolve, reject) => req.session.regenerate(e => e ? reject(e) : resolve()));
1486
1651
  req.session.userId = user.id;
1487
1652
  req.session.userName = user.name;
1488
1653
  req.session.isAdmin = user.is_admin;
@@ -1528,7 +1693,7 @@ class PublisherModule {
1528
1693
  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>';
1529
1694
  content += '<div id="create-task-panel" style="display: none;" class="mt-3">';
1530
1695
  content += '<h3>Create New Publication Task</h3>';
1531
- content += '<form id="create-task-form" method="post" action="/publisher/tasks" class="row g-3">';
1696
+ content += '<form id="create-task-form" method="post" action="/publisher/tasks" class="row g-3">' + this.csrfField(res);
1532
1697
  content += '<div class="col-md-3">';
1533
1698
  content += '<label for="website_id" class="form-label">Target Website</label>';
1534
1699
  content += '<select class="form-select" id="website_id" name="website_id" required>';
@@ -1606,7 +1771,7 @@ class PublisherModule {
1606
1771
  content += '<a href="/publisher/tasks/' + task.id + '/qa-files/index.html" class="btn btn-sm btn-outline-secondary me-1">View IG</a>';
1607
1772
  content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-sm btn-outline-secondary me-1">View QA</a>';
1608
1773
  if (canApprove) {
1609
- content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">';
1774
+ content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">' + this.csrfField(res);
1610
1775
  content += '<button type="submit" name="approve" class="btn btn-sm btn-success me-1">Approve</button>';
1611
1776
  content += '</form>';
1612
1777
  }
@@ -1623,13 +1788,13 @@ class PublisherModule {
1623
1788
  }
1624
1789
 
1625
1790
  if (canDelete) {
1626
- 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.\')">';
1791
+ 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.\')">' + this.csrfField(res);
1627
1792
  content += '<button type="submit" class="btn btn-sm btn-danger">Delete</button>';
1628
1793
  content += '</form>';
1629
1794
  }
1630
1795
 
1631
1796
  if (req.session.userId && task.status === 'failed') {
1632
- content += ' <form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;">';
1797
+ content += ' <form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;">' + this.csrfField(res);
1633
1798
  content += '<button type="submit" class="btn btn-sm btn-warning">Retry</button>';
1634
1799
  content += '</form>';
1635
1800
  }
@@ -1649,7 +1814,7 @@ class PublisherModule {
1649
1814
  templateVars: {
1650
1815
  loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
1651
1816
  loginPath: req.session.userId ? "logout" : 'login',
1652
- loginAction: req.session.userId ? "POST" : 'GET'
1817
+ loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
1653
1818
  }});
1654
1819
  res.setHeader('Content-Type', 'text/html');
1655
1820
  res.send(html);
@@ -1692,6 +1857,18 @@ class PublisherModule {
1692
1857
  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.');
1693
1858
  }
1694
1859
 
1860
+ // Check the repo and branch actually exist before queueing, so a typo is
1861
+ // reported now rather than when the build starts. If GitHub can't be
1862
+ // reached, don't block - the clone will report any real problem.
1863
+ const remote = await gitRemote.checkRemoteBranch(github_org, github_repo, git_branch);
1864
+ if (!remote.ok) {
1865
+ if (remote.unreachable) {
1866
+ this.logger.warn('Could not verify ' + github_org + '/' + github_repo + '#' + git_branch + ' before queueing: ' + remote.error);
1867
+ } else {
1868
+ return res.status(400).send('Invalid task details: ' + remote.error);
1869
+ }
1870
+ }
1871
+
1695
1872
  // Insert task (ID will be auto-generated)
1696
1873
  const result = await new Promise((resolve, reject) => {
1697
1874
  this.db.run(
@@ -1827,7 +2004,7 @@ class PublisherModule {
1827
2004
  res.redirect('/publisher/tasks');
1828
2005
  } catch (error) {
1829
2006
  this.logger.error('Error deleting task:', error);
1830
- res.status(500).send('Failed to delete task: ' + error.message);
2007
+ res.status(500).type('text/plain').send('Failed to delete task: ' + escape(error.message));
1831
2008
  }
1832
2009
  } finally {
1833
2010
  this.stats.countRequest('delete-task', Date.now() - start);
@@ -1858,7 +2035,7 @@ class PublisherModule {
1858
2035
  res.redirect('/publisher/tasks/' + newTaskId + '/history');
1859
2036
  } catch (error) {
1860
2037
  this.logger.error('Error retrying task:', error);
1861
- res.status(500).send('Failed to retry task: ' + error.message);
2038
+ res.status(500).type('text/plain').send('Failed to retry task: ' + escape(error.message));
1862
2039
  }
1863
2040
  } finally {
1864
2041
  this.stats.countRequest('retry-task', Date.now() - start);
@@ -1908,6 +2085,9 @@ class PublisherModule {
1908
2085
  let content = '<h3>Task Output: #' + task.id + ' - ' + escape(task.npm_package_id) + '#' + escape(task.version) + '</h3>';
1909
2086
  content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + escape(task.status) + '</span></p>';
1910
2087
  content += '<p><strong>GitHub:</strong> ' + escape(task.github_org) + '/' + escape(task.github_repo) + ' (' + escape(task.git_branch) + ')</p>';
2088
+ if (task.release_url) {
2089
+ content += '<p><strong>Release:</strong> <a href="' + escape(task.release_url) + '" target="_blank" rel="noopener noreferrer">' + escape(task.release_url) + '</a></p>';
2090
+ }
1911
2091
 
1912
2092
  if (task.publisher_version) {
1913
2093
  content += '<p><strong>IG Publisher:</strong> <code>' + escape(task.publisher_version) + '</code></p>';
@@ -1966,7 +2146,7 @@ class PublisherModule {
1966
2146
  templateVars: {
1967
2147
  loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
1968
2148
  loginPath: req.session.userId ? "logout" : 'login',
1969
- loginAction: req.session.userId ? "POST" : 'GET'
2149
+ loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
1970
2150
  }});
1971
2151
  res.setHeader('Content-Type', 'text/html');
1972
2152
  res.send(html);
@@ -2053,6 +2233,9 @@ class PublisherModule {
2053
2233
  content += '</div>';
2054
2234
  content += '<div class="col-md-6">';
2055
2235
  content += '<p><strong>GitHub:</strong> ' + escape(task.github_org) + '/' + escape(task.github_repo) + ' (' + escape(task.git_branch) + ')</p>';
2236
+ if (task.release_url) {
2237
+ content += '<p><strong>Release:</strong> <a href="' + escape(task.release_url) + '" target="_blank" rel="noopener noreferrer">' + escape(task.release_url) + '</a></p>';
2238
+ }
2056
2239
  content += '<p><strong>Created by:</strong> ' + escape(task.user_name) + ' (' + escape(task.user_login) + ')</p>';
2057
2240
  if (task.approved_by_name) {
2058
2241
  content += '<p><strong>Approved by:</strong> ' + escape(task.approved_by_name) + '</p>';
@@ -2180,7 +2363,7 @@ class PublisherModule {
2180
2363
  content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-outline-secondary me-2">View QA Report</a>';
2181
2364
  }
2182
2365
  if (req.session.userId && task.status === 'failed') {
2183
- content += '<form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;" class="me-2">';
2366
+ content += '<form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;" class="me-2">' + this.csrfField(res);
2184
2367
  content += '<button type="submit" class="btn btn-warning">Retry</button>';
2185
2368
  content += '</form>';
2186
2369
  }
@@ -2194,7 +2377,7 @@ class PublisherModule {
2194
2377
  (detailIsPostApprovalFailed && req.session.isAdmin)
2195
2378
  );
2196
2379
  if (detailCanDelete) {
2197
- content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" class="me-2" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">';
2380
+ content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" class="me-2" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">' + this.csrfField(res);
2198
2381
  content += '<button type="submit" class="btn btn-danger">Delete</button>';
2199
2382
  content += '</form>';
2200
2383
  }
@@ -2205,7 +2388,7 @@ class PublisherModule {
2205
2388
  templateVars: {
2206
2389
  loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
2207
2390
  loginPath: req.session.userId ? "logout" : 'login',
2208
- loginAction: req.session.userId ? "POST" : 'GET'
2391
+ loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
2209
2392
  }});
2210
2393
  res.setHeader('Content-Type', 'text/html');
2211
2394
  res.send(html);
@@ -2226,7 +2409,7 @@ class PublisherModule {
2226
2409
  if (!website) return res.status(404).send('Website not found');
2227
2410
 
2228
2411
  let content = '<h3>Edit Website</h3>';
2229
- content += '<form method="post" action="/publisher/admin/websites/' + website.id + '/edit" class="row g-3">';
2412
+ content += '<form method="post" action="/publisher/admin/websites/' + website.id + '/edit" class="row g-3">' + this.csrfField(res);
2230
2413
  content += '<div class="col-md-4"><label class="form-label">Website Name</label>';
2231
2414
  content += '<input type="text" class="form-control" name="name" value="' + escape(website.name) + '" required></div>';
2232
2415
  content += '<div class="col-md-4"><label class="form-label">Local Folder</label>';
@@ -2246,7 +2429,7 @@ class PublisherModule {
2246
2429
  content += '</form>';
2247
2430
 
2248
2431
  const html = htmlServer.renderPage('publisher', 'Edit Website - FHIR Publisher', content, {
2249
- templateVars: { loginTitle: (req.session.userName || '') + ' \u2014 Logout', loginPath: 'logout', loginAction: 'POST' }
2432
+ templateVars: { loginTitle: (req.session.userName || '') + ' \u2014 Logout', loginPath: 'logout', loginAction: 'POST', csrfToken: res.locals._csrf || '' }
2250
2433
  });
2251
2434
  res.setHeader('Content-Type', 'text/html');
2252
2435
  res.send(html);
@@ -2297,7 +2480,7 @@ class PublisherModule {
2297
2480
  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>';
2298
2481
  content += '<div id="add-website-panel" style="display: none;" class="mt-3">';
2299
2482
  content += '<h3>Add New Website</h3>';
2300
- content += '<form method="post" action="/publisher/admin/websites" class="row g-3">';
2483
+ content += '<form method="post" action="/publisher/admin/websites" class="row g-3">' + this.csrfField(res);
2301
2484
  content += '<div class="col-md-4">';
2302
2485
  content += '<label for="name" class="form-label">Website Name</label>';
2303
2486
  content += '<input type="text" class="form-control" id="name" name="name" required>';
@@ -2366,7 +2549,7 @@ class PublisherModule {
2366
2549
  templateVars: {
2367
2550
  loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
2368
2551
  loginPath: req.session.userId ? "logout" : 'login',
2369
- loginAction: req.session.userId ? "POST" : 'GET'
2552
+ loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
2370
2553
  }});
2371
2554
  res.setHeader('Content-Type', 'text/html');
2372
2555
  res.send(html);
@@ -2426,7 +2609,7 @@ class PublisherModule {
2426
2609
  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>';
2427
2610
  content += '<div id="add-user-panel" style="display: none;" class="mt-3">';
2428
2611
  content += '<h3>Add New User</h3>';
2429
- content += '<form method="post" action="/publisher/admin/users" class="row g-3">';
2612
+ content += '<form method="post" action="/publisher/admin/users" class="row g-3">' + this.csrfField(res);
2430
2613
  content += '<div class="col-md-3">';
2431
2614
  content += '<label for="name" class="form-label">Full Name</label>';
2432
2615
  content += '<input type="text" class="form-control" id="name" name="name" required>';
@@ -2471,7 +2654,7 @@ class PublisherModule {
2471
2654
  if (websites.length === 0) {
2472
2655
  content += '<p>No websites available for permission assignment.</p>';
2473
2656
  } else {
2474
- content += '<form method="post" action="/publisher/admin/permissions">';
2657
+ content += '<form method="post" action="/publisher/admin/permissions">' + this.csrfField(res);
2475
2658
  content += '<input type="hidden" name="user_id" value="' + user.id + '">';
2476
2659
  content += '<div class="permission-grid">';
2477
2660
  content += '<div><strong>Website</strong></div>';
@@ -2508,7 +2691,7 @@ class PublisherModule {
2508
2691
  templateVars: {
2509
2692
  loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
2510
2693
  loginPath: req.session.userId ? "logout" : 'login',
2511
- loginAction: req.session.userId ? "POST" : 'GET'
2694
+ loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
2512
2695
  }});
2513
2696
  res.setHeader('Content-Type', 'text/html');
2514
2697
  res.send(html);
@@ -2747,10 +2930,12 @@ class PublisherModule {
2747
2930
  await this.logTaskMessage(taskId, 'info', description);
2748
2931
 
2749
2932
  return new Promise((resolve, reject) => {
2750
- const proc = spawn(command, args, {
2751
- stdio: ['pipe', 'pipe', 'pipe'],
2752
- ...options
2753
- });
2933
+ const spawnOptions = { stdio: ['pipe', 'pipe', 'pipe'], ...options };
2934
+ if (command === 'git') {
2935
+ // never let git block on (or fail obscurely at) a credentials prompt
2936
+ spawnOptions.env = gitRemote.gitEnv(spawnOptions.env || process.env);
2937
+ }
2938
+ const proc = spawn(command, args, spawnOptions);
2754
2939
 
2755
2940
  let stdout = '';
2756
2941
  let stderr = '';
@@ -721,7 +721,8 @@ class RegistryModule {
721
721
  * @returns {string} Formatted URL with edition description
722
722
  */
723
723
  _describeSnomedEdition(url) {
724
- if (!url.startsWith('http://snomed.info/sct')) {
724
+ // the test edition is published under xsct (http://snomed.info/xsct/31000003106/...)
725
+ if (!url.startsWith('http://snomed.info/sct') && !url.startsWith('http://snomed.info/xsct')) {
725
726
  return url;
726
727
  }
727
728
 
@@ -745,6 +746,7 @@ class RegistryModule {
745
746
  // Match edition code to description
746
747
  switch (editionCode) {
747
748
  case '900000000000207008': edition = 'Intl'; break;
749
+ case '31000003106': edition = 'Test'; break; // tx-ecosystem test edition (xsct)
748
750
  case '731000124108': edition = 'US'; break;
749
751
  case '32506021000036107': edition = 'AU'; break;
750
752
  case '449081005': edition = 'ES/Intl'; break;