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.
- package/CHANGELOG.md +61 -1
- package/README.md +16 -3
- package/config-template.json +81 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +399 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +241 -56
- package/registry/registry.js +3 -1
- package/server.js +57 -15
- package/stats.js +19 -13
- package/testing/readme.md +144 -0
- package/testing/render.js +723 -0
- package/testing/search.js +378 -0
- package/testing/store.js +410 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +760 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +19 -69
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/editions.js +70 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +2 -2
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +192 -21
- package/tx/workers/validate.js +70 -187
- package/tx/workers/worker.js +12 -35
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
package/publisher/publisher.js
CHANGED
|
@@ -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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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 =
|
|
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
|
|
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
|
|
955
|
-
problems.join('; ') + ').
|
|
956
|
-
'regeneration (e.g. a Jekyll/template
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
await this.runCommand('git', ['
|
|
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
|
-
|
|
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
|
|
2751
|
-
|
|
2752
|
-
|
|
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 = '';
|
package/registry/registry.js
CHANGED
|
@@ -721,7 +721,8 @@ class RegistryModule {
|
|
|
721
721
|
* @returns {string} Formatted URL with edition description
|
|
722
722
|
*/
|
|
723
723
|
_describeSnomedEdition(url) {
|
|
724
|
-
|
|
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;
|