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/CHANGELOG.md +29 -0
- package/config-template.json +11 -0
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +17 -9
- package/package.json +1 -1
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +95 -42
- package/registry/registry.js +3 -1
- package/server.js +6 -1
- package/testing/readme.md +32 -0
- package/testing/render.js +270 -29
- package/testing/store.js +152 -8
- package/testing/testing.js +283 -9
- package/tx/cs/cs-snomed.js +3 -72
- package/tx/sct/editions.js +70 -0
- package/tx/workers/expand.js +2 -2
- package/tx/workers/translate.js +25 -1
- package/tx/workers/validate.js +9 -5
- package/tx/workers/worker.js +3 -35
- package/utilities/submit-test-report.js +1 -1
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
|
-
|
|
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 };
|
package/testing/testing.js
CHANGED
|
@@ -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 {
|
|
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()
|
|
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
|
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|