fhirsmith 0.14.0 → 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/testing/store.js CHANGED
@@ -36,6 +36,8 @@ const SCHEMA = `
36
36
  issued_lo INTEGER NOT NULL,
37
37
  issued_hi INTEGER NOT NULL,
38
38
  size INTEGER NOT NULL,
39
+ run_ms INTEGER, -- how long the run took, when the report says
40
+ test_count INTEGER, -- how many entries in TestReport.test
39
41
  json TEXT NOT NULL
40
42
  );
41
43
 
@@ -48,6 +50,26 @@ const SCHEMA = `
48
50
  display TEXT
49
51
  );
50
52
 
53
+ -- people who can maintain the named links and delete reports. The administrator is not
54
+ -- in here: their password is in the configuration
55
+ CREATE TABLE IF NOT EXISTS users (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ login TEXT UNIQUE NOT NULL,
58
+ name TEXT NOT NULL,
59
+ password_hash TEXT NOT NULL,
60
+ can_edit_links INTEGER NOT NULL DEFAULT 0,
61
+ can_delete_reports INTEGER NOT NULL DEFAULT 0,
62
+ created TEXT NOT NULL
63
+ );
64
+
65
+ -- names (and links) shown in place of known canonical URLs
66
+ CREATE TABLE IF NOT EXISTS links (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ canonical TEXT UNIQUE NOT NULL, -- without any |version
69
+ name TEXT NOT NULL,
70
+ link TEXT
71
+ );
72
+
51
73
  CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_ms);
52
74
  CREATE INDEX IF NOT EXISTS idx_reports_name ON reports(name_lc);
53
75
  CREATE INDEX IF NOT EXISTS idx_reports_tester ON reports(tester_lc);
@@ -84,6 +106,41 @@ function str(v) {
84
106
  return typeof v === 'string' ? v : null;
85
107
  }
86
108
 
109
+ /**
110
+ * How long the run took, in milliseconds, if the report says: from the earliest
111
+ * period.start to the latest period.end across its tests. null if no test has a period
112
+ * with both ends.
113
+ */
114
+ function runLengthOf(report) {
115
+ let lo = null;
116
+ let hi = null;
117
+ for (const t of Array.isArray(report.test) ? report.test : []) {
118
+ const p = t && typeof t === 'object' ? t.period : null;
119
+ if (!p || typeof p !== 'object') {
120
+ continue;
121
+ }
122
+ const s = typeof p.start === 'string' ? Date.parse(p.start) : NaN;
123
+ const e = typeof p.end === 'string' ? Date.parse(p.end) : NaN;
124
+ if (!Number.isNaN(s) && (lo === null || s < lo)) {
125
+ lo = s;
126
+ }
127
+ if (!Number.isNaN(e) && (hi === null || e > hi)) {
128
+ hi = e;
129
+ }
130
+ }
131
+ return lo !== null && hi !== null && hi >= lo ? hi - lo : null;
132
+ }
133
+
134
+ /** How many tests the report has (entries in TestReport.test). */
135
+ function testCountOf(report) {
136
+ return Array.isArray(report.test) ? report.test.length : 0;
137
+ }
138
+
139
+ /** A canonical URL without its |version. */
140
+ function unversioned(url) {
141
+ return typeof url === 'string' && url.includes('|') ? url.substring(0, url.indexOf('|')) : url;
142
+ }
143
+
87
144
  class TestReportStore {
88
145
  /**
89
146
  * @param {string} dbPath - file name, or ':memory:'
@@ -93,16 +150,13 @@ class TestReportStore {
93
150
  this.db.pragma('journal_mode = WAL');
94
151
  this.db.pragma('foreign_keys = ON');
95
152
  this.db.exec(SCHEMA);
96
- // participants.version was added after the first databases were made
97
- if (!this.db.prepare('PRAGMA table_info(participants)').all().some(c => c.name === 'version')) {
98
- this.db.exec('ALTER TABLE participants ADD COLUMN version TEXT');
99
- }
153
+ this.migrate();
100
154
  this.stmts = {
101
155
  insert: this.db.prepare(`
102
156
  INSERT INTO reports (id, received, received_ms, ip, name, name_lc, status, result, score,
103
- tester, tester_lc, test_script, issued, issued_lo, issued_hi, size, json)
157
+ tester, tester_lc, test_script, issued, issued_lo, issued_hi, size, run_ms, test_count, json)
104
158
  VALUES (@id, @received, @received_ms, @ip, @name, @name_lc, @status, @result, @score,
105
- @tester, @tester_lc, @test_script, @issued, @issued_lo, @issued_hi, @size, @json)`),
159
+ @tester, @tester_lc, @test_script, @issued, @issued_lo, @issued_hi, @size, @run_ms, @test_count, @json)`),
106
160
  insertParticipant: this.db.prepare(
107
161
  'INSERT INTO participants (report_id, seq, type, uri, version, display) VALUES (?, ?, ?, ?, ?, ?)'),
108
162
  read: this.db.prepare('SELECT json FROM reports WHERE id = ?'),
@@ -112,6 +166,31 @@ class TestReportStore {
112
166
  };
113
167
  }
114
168
 
169
+ /** Bring a database made by an earlier version of the module up to date. */
170
+ migrate() {
171
+ const has = (table, col) => this.db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === col);
172
+ if (!has('participants', 'version')) {
173
+ this.db.exec('ALTER TABLE participants ADD COLUMN version TEXT');
174
+ }
175
+ // columns worked out from the report: add them, and fill them in for the reports
176
+ // already there
177
+ const derived = [['run_ms', runLengthOf], ['test_count', testCountOf]];
178
+ for (const [col, fn] of derived) {
179
+ if (!has('reports', col)) {
180
+ this.db.exec(`ALTER TABLE reports ADD COLUMN ${col} INTEGER`);
181
+ const update = this.db.prepare(`UPDATE reports SET ${col} = ? WHERE id = ?`);
182
+ this.db.transaction(() => {
183
+ for (const row of this.db.prepare('SELECT id, json FROM reports').all()) {
184
+ const v = fn(JSON.parse(row.json));
185
+ if (v !== null) {
186
+ update.run(v, row.id);
187
+ }
188
+ }
189
+ })();
190
+ }
191
+ }
192
+ }
193
+
115
194
  close() {
116
195
  if (this.db) {
117
196
  this.db.close();
@@ -145,6 +224,8 @@ class TestReportStore {
145
224
  issued_lo: issued.lo,
146
225
  issued_hi: issued.hi,
147
226
  size: Buffer.byteLength(json, 'utf8'),
227
+ run_ms: runLengthOf(report),
228
+ test_count: testCountOf(report),
148
229
  json
149
230
  };
150
231
  this.db.transaction(() => {
@@ -191,7 +272,7 @@ class TestReportStore {
191
272
  }
192
273
  const cols = options.withJson
193
274
  ? 'r.id, r.json'
194
- : 'r.id, r.received, r.name, r.status, r.result, r.score, r.tester, r.test_script, r.issued';
275
+ : 'r.id, r.received, r.name, r.status, r.result, r.score, r.tester, r.test_script, r.issued, r.run_ms, r.test_count';
195
276
  const rows = this.db.prepare(
196
277
  `SELECT ${cols} FROM reports r ${where} ORDER BY ${search.orderBy} LIMIT ? OFFSET ?`
197
278
  ).all(...search.args, search.count, search.offset);
@@ -211,6 +292,69 @@ class TestReportStore {
211
292
  return { total, rows };
212
293
  }
213
294
 
295
+ /** Whether any report has a participant of this type. */
296
+ hasParticipantType(type) {
297
+ return !!this.db.prepare('SELECT 1 FROM participants WHERE type = ? LIMIT 1').get(type);
298
+ }
299
+
300
+ // ---- named links ----
301
+
302
+ /** @returns {Array<{id, canonical, name, link}>} sorted by name */
303
+ links() {
304
+ return this.db.prepare('SELECT id, canonical, name, link FROM links ORDER BY name COLLATE NOCASE, canonical').all();
305
+ }
306
+
307
+ /** Add a link, or replace the one for the same canonical. */
308
+ saveLink(canonical, name, link) {
309
+ this.db.prepare(`INSERT INTO links (canonical, name, link) VALUES (?, ?, ?)
310
+ ON CONFLICT(canonical) DO UPDATE SET name = excluded.name, link = excluded.link`)
311
+ .run(unversioned(canonical), name, link || null);
312
+ }
313
+
314
+ updateLink(id, canonical, name, link) {
315
+ return this.db.prepare('UPDATE links SET canonical = ?, name = ?, link = ? WHERE id = ?')
316
+ .run(unversioned(canonical), name, link || null, id).changes > 0;
317
+ }
318
+
319
+ deleteLink(id) {
320
+ return this.db.prepare('DELETE FROM links WHERE id = ?').run(id).changes > 0;
321
+ }
322
+
323
+ // ---- users ----
324
+
325
+ /** @returns {Array} users, without their password hashes */
326
+ users() {
327
+ return this.db.prepare('SELECT id, login, name, can_edit_links, can_delete_reports, created FROM users ORDER BY login').all();
328
+ }
329
+
330
+ /** @returns {Object|null} the user, including the password hash */
331
+ userByLogin(login) {
332
+ return this.db.prepare('SELECT * FROM users WHERE login = ?').get(login) || null;
333
+ }
334
+
335
+ userById(id) {
336
+ return this.db.prepare('SELECT id, login, name, can_edit_links, can_delete_reports FROM users WHERE id = ?').get(id) || null;
337
+ }
338
+
339
+ createUser(login, name, passwordHash, rights) {
340
+ this.db.prepare(`INSERT INTO users (login, name, password_hash, can_edit_links, can_delete_reports, created)
341
+ VALUES (?, ?, ?, ?, ?, ?)`).run(login, name, passwordHash, rights.editLinks ? 1 : 0, rights.deleteReports ? 1 : 0,
342
+ new Date().toISOString());
343
+ }
344
+
345
+ updateUser(id, name, rights, passwordHash) {
346
+ const n = this.db.prepare('UPDATE users SET name = ?, can_edit_links = ?, can_delete_reports = ? WHERE id = ?')
347
+ .run(name, rights.editLinks ? 1 : 0, rights.deleteReports ? 1 : 0, id).changes;
348
+ if (n > 0 && passwordHash) {
349
+ this.db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, id);
350
+ }
351
+ return n > 0;
352
+ }
353
+
354
+ deleteUser(id) {
355
+ return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
356
+ }
357
+
214
358
  /** The distinct values in a column, for filter drop-downs. */
215
359
  distinct(column) {
216
360
  if (!['status', 'result'].includes(column)) {
@@ -263,4 +407,4 @@ class TestReportStore {
263
407
  }
264
408
  }
265
409
 
266
- module.exports = { TestReportStore, testScriptOf };
410
+ module.exports = { TestReportStore, testScriptOf, runLengthOf, testCountOf, unversioned };
@@ -15,6 +15,14 @@
15
15
  * GET {base}/metadata CapabilityStatement
16
16
  * GET {base} the filterable list (HTML)
17
17
  * GET {base}/summary latest result per test script x participant (HTML)
18
+ * GET {base}/login login for the administration pages; POST {base}/logout
19
+ * GET {base}/admin/links names for known canonical URLs (users who can edit links)
20
+ * GET {base}/admin/users users and their rights (the administrator only)
21
+ *
22
+ * Logins work as the publisher's do: a session cookie, bcrypt password hashes, a rate
23
+ * limited login. The administrator logs in as 'admin' with the adminPassword from the
24
+ * configuration, and manages the other users; a user can have either or both of the
25
+ * rights to edit the named links and to delete reports.
18
26
  *
19
27
  * R4 and R5 TestReports are treated as the same thing. Reports can't be changed once
20
28
  * received: each POST is a new report, even when it's the same report as before.
@@ -27,14 +35,20 @@ const path = require('path');
27
35
  const crypto = require('crypto');
28
36
  const rateLimit = require('express-rate-limit');
29
37
  const escape = require('escape-html');
38
+ const session = require('express-session');
39
+ const lusca = require('lusca');
40
+ const bcrypt = require('bcrypt');
30
41
 
31
42
  const folders = require('../library/folder-setup');
32
43
  const htmlServer = require('../library/html-server');
33
44
  const { tokenMatches, tokenConfigured } = require('../library/request-token');
45
+ const { requireSameOrigin } = require('../library/same-origin');
34
46
  const packageJson = require('../package.json');
35
47
  const { TestReportStore } = require('./store');
36
48
  const { parseSearch, dateRange, capabilitySearchParams } = require('./search');
37
- const { renderList, renderSummary, renderReport, listQueryToSearch } = require('./render');
49
+ const {
50
+ renderList, renderSummary, renderReport, renderLogin, renderLinks, renderUsers, listQueryToSearch
51
+ } = require('./render');
38
52
 
39
53
  const TEMPLATE = 'testing';
40
54
  const DEFAULT_MAX_SIZE = 500 * 1024;
@@ -140,12 +154,60 @@ class TestingModule {
140
154
 
141
155
  htmlServer.loadTemplate(TEMPLATE, path.join(__dirname, 'testing-template.html'));
142
156
 
157
+ // the administrator's password is checked the same way as everyone else's: bcrypt against
158
+ // a hash, made once here, of the configured password
159
+ this.adminHash = tokenConfigured(this.config.adminPassword) ? bcrypt.hashSync(this.config.adminPassword, 10) : null;
160
+
161
+ this.setupSession();
143
162
  this.setupRoutes();
163
+ this.setupAdminRoutes();
144
164
  this.setupRetention();
145
165
  this.log.info(`Testing module: ${this.store.count()} reports in ${dbPath}; ` +
146
166
  `posting ${tokenConfigured(this.config.token) ? 'requires a token' : 'is open'}, max ${this.maxSize} bytes`);
147
167
  }
148
168
 
169
+ /**
170
+ * The session (login) cookie and CSRF protection. Neither is put on the whole router:
171
+ * the FHIR API (POST and DELETE of TestReports) is authenticated by a token in a header,
172
+ * not a cookie, so it needs neither and must not be made to carry a CSRF token. The web
173
+ * pages get this.web; every form post gets sameOrigin + this.session + a form parser +
174
+ * this.csrf (see setupAdminRoutes).
175
+ *
176
+ * CSRF has two layers: lusca's per-session token, which every form carries in a hidden
177
+ * _csrf field, and the Origin check (library/same-origin). The cookie is also SameSite=Lax.
178
+ */
179
+ setupSession() {
180
+ if (!this.config.sessionSecret) {
181
+ // as for the publisher: never a constant secret, which would be a published signing key
182
+ this.log.warn('testing: no sessionSecret configured - using a random one, so logins will not ' +
183
+ 'survive a restart. Set modules.testing.sessionSecret.');
184
+ }
185
+ this.session = session({
186
+ name: 'testing.sid',
187
+ secret: this.config.sessionSecret || crypto.randomBytes(64).toString('hex'),
188
+ resave: false,
189
+ saveUninitialized: false,
190
+ cookie: {
191
+ // HTTPS only unless cookieSecure is false - see the publisher's notes on nginx
192
+ secure: this.config.cookieSecure ?? true,
193
+ // the browser will not send the cookie on a form posted from another site, which,
194
+ // with the Origin check on every form post, is the CSRF protection
195
+ sameSite: 'lax',
196
+ httpOnly: true,
197
+ maxAge: 24 * 60 * 60 * 1000
198
+ }
199
+ });
200
+ // used directly (not wrapped) in every form post's route, so static analysis can see
201
+ // it there. A bad or missing token comes out as an error, which the error handler at the
202
+ // end of setupAdminRoutes turns into a page
203
+ this.csrf = lusca.csrf({ key: '_csrf' });
204
+ // the pages show a logout form (which needs a token) only to someone logged in. Making
205
+ // a token stores a secret in the session, so doing it for every visitor would give
206
+ // every crawler a session
207
+ const csrfIfLoggedIn = (req, res, next) => this.currentUser(req) ? this.csrf(req, res, next) : next();
208
+ this.web = [this.session, csrfIfLoggedIn];
209
+ }
210
+
149
211
  setupRoutes() {
150
212
  const r = this.router;
151
213
  const rl = this.config.rateLimit || {};
@@ -161,13 +223,13 @@ class TestingModule {
161
223
  }));
162
224
  }
163
225
 
164
- r.get('/', (req, res) => this.handle(req, res, 'list', () => this.htmlList(req, res)));
165
- r.get('/summary', (req, res) => this.handle(req, res, 'summary', () => this.htmlSummary(req, res)));
226
+ r.get('/', ...this.web, (req, res) => this.handle(req, res, 'list', () => this.htmlList(req, res)));
227
+ r.get('/summary', ...this.web, (req, res) => this.handle(req, res, 'summary', () => this.htmlSummary(req, res)));
166
228
  r.get('/metadata', (req, res) => this.handle(req, res, 'metadata', () => this.metadata(req, res)));
167
229
  r.post('/TestReport', ...posting, (req, res) => this.handle(req, res, 'create', () => this.create(req, res)));
168
- r.get('/TestReport', (req, res) => this.handle(req, res, 'search', () =>
230
+ r.get('/TestReport', ...this.web, (req, res) => this.handle(req, res, 'search', () =>
169
231
  wantsHtml(req) ? this.htmlList(req, res) : this.search(req, res)));
170
- r.get('/TestReport/:id', (req, res) => this.handle(req, res, 'read', () => this.read(req, res)));
232
+ r.get('/TestReport/:id', ...this.web, (req, res) => this.handle(req, res, 'read', () => this.read(req, res)));
171
233
  r.delete('/TestReport/:id', (req, res) => this.handle(req, res, 'delete', () => this.remove(req, res)));
172
234
  }
173
235
 
@@ -337,7 +399,7 @@ class TestingModule {
337
399
  return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No report with id '${req.params.id}'`), 404);
338
400
  }
339
401
  return this.sendHtml(res, 'Test Report: ' + (typeof report.name === 'string' ? report.name : report.id),
340
- renderReport(report, req.baseUrl), start);
402
+ renderReport(report, req.baseUrl, { names: this.names(), user: this.currentUser(req), csrf: res.locals._csrf }), start);
341
403
  }
342
404
  if (!report) {
343
405
  return this.sendOutcome(res, 404, 'not-found', `No TestReport with id '${req.params.id}'`);
@@ -468,9 +530,12 @@ class TestingModule {
468
530
  }
469
531
  const results = this.store.search(search);
470
532
  const content = prefix + renderList(query, results, search, {
471
- statuses: this.store.distinct('status'),
472
533
  results: this.store.distinct('result'),
473
- base: req.baseUrl
534
+ base: req.baseUrl,
535
+ names: this.names(),
536
+ user: this.currentUser(req),
537
+ csrf: res.locals._csrf,
538
+ showClient: this.store.hasParticipantType('client')
474
539
  });
475
540
  this.sendHtml(res, 'Test Reports', content, start);
476
541
  }
@@ -478,7 +543,216 @@ class TestingModule {
478
543
  async htmlSummary(req, res) {
479
544
  const start = Date.now();
480
545
  const by = req.query.by === 'tester' ? 'tester' : 'participant';
481
- this.sendHtml(res, 'Test Report Summary', renderSummary(this.store.summary(by), by, req.baseUrl, this.store.testerCounts()), start);
546
+ this.sendHtml(res, 'Test Report Summary', renderSummary(this.store.summary(by), by, req.baseUrl, this.store.testerCounts(),
547
+ { names: this.names(), user: this.currentUser(req), csrf: res.locals._csrf }), start);
548
+ }
549
+
550
+ // ---- users, named links, and the administration pages ----
551
+
552
+ /** The named links, by unversioned canonical. Cached until they change. */
553
+ names() {
554
+ if (!this.namesCache) {
555
+ this.namesCache = new Map(this.store.links().map(l => [l.canonical, l]));
556
+ }
557
+ return this.namesCache;
558
+ }
559
+
560
+ /**
561
+ * Who is logged in, with their rights as they are now (not as they were at login, so
562
+ * taking a right away, or deleting the user, takes effect at once).
563
+ */
564
+ currentUser(req) {
565
+ const s = req.session;
566
+ if (!s) {
567
+ return null;
568
+ }
569
+ if (s.testingAdmin) {
570
+ return { name: 'Administrator', isAdmin: true, canEditLinks: true, canDeleteReports: true };
571
+ }
572
+ if (s.testingUserId) {
573
+ const u = this.store.userById(s.testingUserId);
574
+ if (u) {
575
+ return { id: u.id, name: u.name, isAdmin: false, canEditLinks: !!u.can_edit_links, canDeleteReports: !!u.can_delete_reports };
576
+ }
577
+ }
578
+ return null;
579
+ }
580
+
581
+ setupAdminRoutes() {
582
+ const r = this.router;
583
+ const form = express.urlencoded({ extended: false, limit: '64kb' });
584
+ const loginLimiter = rateLimit({
585
+ windowMs: 15 * 60 * 1000,
586
+ limit: this.config.loginAttemptsPerWindow ?? 20,
587
+ standardHeaders: 'draft-7',
588
+ legacyHeaders: false,
589
+ handler: (req, res) => this.sendLogin(req, res, 429, 'Too many login attempts - try again later')
590
+ });
591
+ // every form post must come from one of our own pages
592
+ const sameOrigin = requireSameOrigin((req, res) =>
593
+ htmlServer.sendErrorResponse(res, TEMPLATE, new Error('Cross-site form posts are not accepted'), 403));
594
+ // a right, or the administrator
595
+ const need = (right) => (req, res, next) => {
596
+ const user = this.currentUser(req);
597
+ if (!user) {
598
+ return res.redirect(req.baseUrl + '/login');
599
+ }
600
+ if (!(user.isAdmin || (right && user[right]))) {
601
+ return htmlServer.sendErrorResponse(res, TEMPLATE, new Error('You do not have permission to do that'), 403);
602
+ }
603
+ req.user = user;
604
+ next();
605
+ };
606
+ const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
607
+ // Every page with a form has this.session, this.csrf; every form post has sameOrigin,
608
+ // this.session, form, this.csrf. They are written out in each route rather than spread
609
+ // from an array, so that CodeQL can see the CSRF middleware on every cookie-using route.
610
+
611
+ r.get('/login', this.session, this.csrf, h('login', (req, res) => this.sendLogin(req, res, 200)));
612
+ r.post('/login', sameOrigin, loginLimiter, this.session, form, this.csrf, h('login', (req, res) => this.login(req, res)));
613
+ r.post('/logout', sameOrigin, this.session, form, this.csrf, h('logout', (req, res) => {
614
+ req.session.destroy(() => res.redirect(req.baseUrl || '/'));
615
+ }));
616
+
617
+ r.get('/admin/links', this.session, this.csrf, need('canEditLinks'), h('links', (req, res) => this.sendLinks(req, res)));
618
+ r.post('/admin/links', sameOrigin, this.session, form, this.csrf, need('canEditLinks'), h('links', (req, res) => this.saveLink(req, res, null)));
619
+ r.post('/admin/links/:id', sameOrigin, this.session, form, this.csrf, need('canEditLinks'), h('links', (req, res) => this.saveLink(req, res, req.params.id)));
620
+ r.post('/admin/links/:id/delete', sameOrigin, this.session, form, this.csrf, need('canEditLinks'), h('links', (req, res) => {
621
+ this.store.deleteLink(parseInt(req.params.id, 10));
622
+ this.namesCache = null;
623
+ res.redirect(req.baseUrl + '/admin/links');
624
+ }));
625
+
626
+ r.get('/admin/users', this.session, this.csrf, need(null), h('users', (req, res) => this.sendUsers(req, res)));
627
+ r.post('/admin/users', sameOrigin, this.session, form, this.csrf, need(null), h('users', (req, res) => this.saveUser(req, res, null)));
628
+ r.post('/admin/users/:id', sameOrigin, this.session, form, this.csrf, need(null), h('users', (req, res) => this.saveUser(req, res, req.params.id)));
629
+ r.post('/admin/users/:id/delete', sameOrigin, this.session, form, this.csrf, need(null), h('users', (req, res) => {
630
+ this.store.deleteUser(parseInt(req.params.id, 10));
631
+ this.log.info(`Testing module: user ${req.params.id} deleted`);
632
+ res.redirect(req.baseUrl + '/admin/users');
633
+ }));
634
+
635
+ r.post('/TestReport/:id/delete', sameOrigin, this.session, form, this.csrf, need('canDeleteReports'), h('delete', (req, res) => {
636
+ if (!this.store.delete(req.params.id)) {
637
+ return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No report with id '${req.params.id}'`), 404);
638
+ }
639
+ this.log.info(`Testing module: report ${req.params.id} deleted by ${req.user.name}`);
640
+ res.redirect(req.baseUrl);
641
+ }));
642
+
643
+ // lusca rejects a form post with a missing or wrong token by passing on an error
644
+ // ("CSRF token missing" / "CSRF token mismatch"); anything else goes on as it was
645
+ r.use((err, req, res, next) => {
646
+ if (err && typeof err.message === 'string' && err.message.startsWith('CSRF token')) {
647
+ return htmlServer.sendErrorResponse(res, TEMPLATE,
648
+ new Error('The form has expired or did not come from this site - reload the page and try again'), 403);
649
+ }
650
+ next(err);
651
+ });
652
+ }
653
+
654
+ sendLogin(req, res, status, error) {
655
+ res.status(status);
656
+ this.sendHtml(res, 'Login', renderLogin(req.baseUrl, error, res.locals._csrf), Date.now());
657
+ }
658
+
659
+ async login(req, res) {
660
+ const login = typeof req.body.login === 'string' ? req.body.login.trim() : '';
661
+ const password = typeof req.body.password === 'string' ? req.body.password : '';
662
+ let ok = false;
663
+ const regenerate = () => new Promise((resolve, reject) => req.session.regenerate(e => e ? reject(e) : resolve()));
664
+ if (login === 'admin') {
665
+ if (this.adminHash && await bcrypt.compare(password, this.adminHash)) {
666
+ await regenerate();
667
+ req.session.testingAdmin = true;
668
+ ok = true;
669
+ }
670
+ } else if (login) {
671
+ const user = this.store.userByLogin(login);
672
+ if (user && await bcrypt.compare(password, user.password_hash)) {
673
+ await regenerate();
674
+ req.session.testingUserId = user.id;
675
+ ok = true;
676
+ }
677
+ }
678
+ if (!ok) {
679
+ this.log.info(`Testing module: failed login for '${login}' from ${req.ip}`);
680
+ return this.sendLogin(req, res, 401, 'Unknown username or wrong password');
681
+ }
682
+ this.log.info(`Testing module: '${login}' logged in from ${req.ip}`);
683
+ res.redirect(req.baseUrl || '/');
684
+ }
685
+
686
+ sendLinks(req, res, message, status = 200) {
687
+ res.status(status);
688
+ this.sendHtml(res, 'Named Links', renderLinks(this.store.links(), { base: req.baseUrl, user: req.user, csrf: res.locals._csrf }, message), Date.now());
689
+ }
690
+
691
+ saveLink(req, res, id) {
692
+ const canonical = typeof req.body.canonical === 'string' ? req.body.canonical.trim() : '';
693
+ const name = typeof req.body.name === 'string' ? req.body.name.trim() : '';
694
+ const link = typeof req.body.link === 'string' ? req.body.link.trim() : '';
695
+ let problem = null;
696
+ if (!canonical || /\s/.test(canonical) || canonical.length > 1024) {
697
+ problem = 'The canonical must be a URL with no spaces';
698
+ } else if (!name || name.length > 200) {
699
+ problem = 'A name is required (up to 200 characters)';
700
+ } else if (link && !/^https?:\/\/[^\s"'<>]+$/i.test(link)) {
701
+ problem = 'The link must be an http or https URL';
702
+ }
703
+ if (problem) {
704
+ return this.sendLinks(req, res, problem, 400);
705
+ }
706
+ try {
707
+ if (id === null) {
708
+ this.store.saveLink(canonical, name, link);
709
+ } else if (!this.store.updateLink(parseInt(id, 10), canonical, name, link)) {
710
+ return this.sendLinks(req, res, 'That link no longer exists', 404);
711
+ }
712
+ } catch (e) {
713
+ if (/UNIQUE/.test(e.message)) {
714
+ return this.sendLinks(req, res, `There is already a name for ${canonical}`, 409);
715
+ }
716
+ throw e;
717
+ }
718
+ this.namesCache = null;
719
+ res.redirect(req.baseUrl + '/admin/links');
720
+ }
721
+
722
+ sendUsers(req, res, message, status = 200) {
723
+ res.status(status);
724
+ this.sendHtml(res, 'Users', renderUsers(this.store.users(), { base: req.baseUrl, user: req.user, csrf: res.locals._csrf }, message), Date.now());
725
+ }
726
+
727
+ async saveUser(req, res, id) {
728
+ const login = typeof req.body.login === 'string' ? req.body.login.trim() : '';
729
+ const name = typeof req.body.name === 'string' ? req.body.name.trim() : '';
730
+ const password = typeof req.body.password === 'string' ? req.body.password : '';
731
+ const rights = { editLinks: req.body.editLinks === '1', deleteReports: req.body.deleteReports === '1' };
732
+ let problem = null;
733
+ if (id === null && !/^[A-Za-z0-9._@-]{1,64}$/.test(login)) {
734
+ problem = 'The username must be 1-64 letters, digits, or . _ @ -';
735
+ } else if (id === null && login.toLowerCase() === 'admin') {
736
+ problem = "'admin' is the administrator's login";
737
+ } else if (!name || name.length > 100) {
738
+ problem = 'A name is required (up to 100 characters)';
739
+ } else if ((id === null || password) && password.length < 8) {
740
+ problem = 'Passwords must be at least 8 characters';
741
+ }
742
+ if (problem) {
743
+ return this.sendUsers(req, res, problem, 400);
744
+ }
745
+ const hash = password ? await bcrypt.hash(password, 10) : null;
746
+ if (id === null) {
747
+ if (this.store.userByLogin(login)) {
748
+ return this.sendUsers(req, res, `There is already a user '${login}'`, 409);
749
+ }
750
+ this.store.createUser(login, name, hash, rights);
751
+ this.log.info(`Testing module: user '${login}' created`);
752
+ } else if (!this.store.updateUser(parseInt(id, 10), name, rights, hash)) {
753
+ return this.sendUsers(req, res, 'That user no longer exists', 404);
754
+ }
755
+ res.redirect(req.baseUrl + '/admin/users');
482
756
  }
483
757
  }
484
758
 
@@ -16,6 +16,7 @@ const {ConceptMap} = require("../library/conceptmap");
16
16
  const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
17
17
  const {Issue} = require("../library/operation-outcome");
18
18
  const {debugLog} = require("../operation-context");
19
+ const {editionName, editionCode} = require("../sct/editions");
19
20
  const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index");
20
21
  const Logger = require("../../library/logger");
21
22
 
@@ -3038,81 +3039,11 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
3038
3039
  }
3039
3040
 
3040
3041
  function getEditionName(edition) {
3041
- const editionMap = {
3042
- '900000000000207008': 'International Edition',
3043
- // The terminology-ecosystem test distribution, published under xsct - see the
3044
- // tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
3045
- '31000003106': 'Test Edition',
3046
- '449081005': 'International Spanish Edition',
3047
- '11000221109': 'Argentinian Edition',
3048
- '32506021000036107': 'Australian Edition (with drug extension)',
3049
- '11000234105': 'Austrian Edition',
3050
- '11000172109': 'Belgian Edition',
3051
- '20621000087109': 'Canadian English Edition',
3052
- '20611000087101': 'Canadian Canadian French Edition',
3053
- '554471000005108': 'Danish Edition',
3054
- '11000279109': 'Czech Edition',
3055
- '11000181102': 'Estonian Edition',
3056
- '11000229106': 'Finnish Edition',
3057
- '11000274103': 'German Edition',
3058
- '1121000189102': 'Indian Edition',
3059
- '827022005': 'IPS Terminology',
3060
- '11000220105': 'Irish Edition',
3061
- '11000146104': 'Netherlands Edition',
3062
- '21000210109': 'New Zealand Edition',
3063
- '51000202101': 'Norwegian Edition',
3064
- '11000267109': 'Republic of Korea Edition (South Korea)',
3065
- '900000001000122104': 'Spanish National Edition',
3066
- '45991000052106': 'Swedish Edition',
3067
- '2011000195101': 'Swiss Edition',
3068
- '83821000000107': 'UK Edition',
3069
- '999000021000000109': 'UK Clinical Edition',
3070
- '5631000179106': 'Uruguay Edition',
3071
- '731000124108': 'US Edition',
3072
- '21000325107': 'Chilean Edition',
3073
- '5991000124107': 'US Edition (with ICD-10-CM maps)'
3074
- };
3075
-
3076
- return editionMap[edition] || 'Unknown Edition';
3042
+ return editionName(edition) || 'Unknown Edition';
3077
3043
  }
3078
3044
 
3079
3045
  function getEditionCode(edition) {
3080
- const editionMap = {
3081
- '900000000000207008': 'Intl',
3082
- // The terminology-ecosystem test distribution, published under xsct - see the
3083
- // tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
3084
- '31000003106': 'Test',
3085
- '449081005': 'es',
3086
- '11000221109': 'AR-es',
3087
- '32506021000036107': 'AU+',
3088
- '11000234105': 'AT',
3089
- '11000172109': 'BE',
3090
- '20621000087109': 'CA-en',
3091
- '20611000087101': 'CA-fr',
3092
- '554471000005108': 'DK',
3093
- '11000279109': 'CZ',
3094
- '11000181102': 'ES',
3095
- '11000229106': 'FI',
3096
- '11000274103': 'DE',
3097
- '1121000189102': 'IN',
3098
- '827022005': 'IPS',
3099
- '11000220105': 'IE',
3100
- '11000146104': 'NL',
3101
- '21000210109': 'NZ',
3102
- '51000202101': 'NO',
3103
- '11000267109': 'KR',
3104
- '900000001000122104': 'ES-es',
3105
- '45991000052106': 'SW',
3106
- '2011000195101': 'CH',
3107
- '83821000000107': 'UK',
3108
- '999000021000000109': 'UK-Clinical',
3109
- '5631000179106': 'UR',
3110
- '731000124108': 'US',
3111
- '21000325107': 'CL',
3112
- '5991000124107': 'US+)'
3113
- };
3114
-
3115
- return editionMap[edition] || 'Unknown Edition';
3046
+ return editionCode(edition) || 'Unknown Edition';
3116
3047
  }
3117
3048
 
3118
3049