fhirsmith 0.14.0 → 0.14.2
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 +64 -0
- package/README.md +3 -2
- package/config-template.json +20 -0
- package/library/fhir-openapi-schema.js +441 -0
- package/library/html-server.js +9 -3
- package/library/languages.js +15 -0
- package/library/npm-audit.js +265 -0
- package/library/openapi-doc.js +557 -0
- package/library/same-origin.js +62 -0
- package/library/utilities.js +21 -0
- package/nginx.md +17 -9
- package/package.json +10 -4
- package/packages/openapi.js +13 -0
- package/packages/openapi.yaml +603 -0
- package/packages/packages-template.html +4 -2
- package/packages/packages.js +286 -81
- package/packages/readme.md +120 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +95 -42
- package/registry/api.js +117 -41
- package/registry/crawler.js +24 -0
- package/registry/fhirsmith-releases.js +170 -0
- package/registry/model.js +10 -0
- package/registry/openapi.js +13 -0
- package/registry/openapi.yaml +311 -0
- package/registry/readme.md +107 -208
- package/registry/registry-template.html +4 -1
- package/registry/registry.js +183 -20
- package/server.js +25 -6
- package/testing/openapi-schemas.config.js +58 -0
- package/testing/openapi-schemas.json +1920 -0
- package/testing/openapi.js +15 -0
- package/testing/openapi.yaml +273 -0
- package/testing/readme.md +75 -1
- package/testing/render.js +270 -29
- package/testing/search.js +1 -1
- package/testing/store.js +152 -8
- package/testing/testing-template.html +2 -0
- package/testing/testing.js +307 -9
- package/translations/Messages.properties +1 -0
- package/tx/README.md +58 -0
- package/tx/cm/cm-database.js +10 -1
- package/tx/cm/cm-package.js +8 -1
- package/tx/cs/cs-rxnorm.js +23 -6
- package/tx/cs/cs-snomed.js +3 -72
- package/tx/html/tx-template.html +2 -0
- package/tx/library/canonical-resource.js +50 -1
- package/tx/library/codesystem.js +1 -0
- package/tx/library/conceptmap.js +1 -0
- package/tx/library/valueset.js +16 -0
- package/tx/library.js +17 -2
- package/tx/openapi-operations.js +363 -0
- package/tx/openapi-schemas.config.js +118 -0
- package/tx/openapi-schemas.json +7903 -0
- package/tx/openapi.js +289 -0
- package/tx/openapi.yaml +151 -0
- package/tx/operation-context.js +15 -0
- package/tx/params.js +3 -12
- package/tx/provider.js +10 -1
- package/tx/sct/editions.js +70 -0
- package/tx/tx-html.js +6 -0
- package/tx/tx.js +55 -0
- package/tx/vs/vs-database.js +14 -5
- package/tx/vs/vs-package.js +10 -0
- package/tx/workers/expand.js +37 -18
- package/tx/workers/metadata.js +0 -13
- package/tx/workers/translate.js +25 -1
- package/tx/workers/validate.js +74 -24
- package/tx/workers/worker.js +32 -43
- package/utilities/extract-schema-json.js +167 -0
- package/utilities/generate-openapi-schemas.js +49 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +2 -4
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Package Server
|
|
2
|
+
|
|
3
|
+
A registry of FHIR NPM packages. It crawls the package feeds listed in the FHIR IG
|
|
4
|
+
registry, stores every package version it finds, and serves them using the npm
|
|
5
|
+
registry protocol, with FHIR-specific search on top (by canonical URL, FHIR version
|
|
6
|
+
and dependency). It is the server behind packages2.fhir.org.
|
|
7
|
+
|
|
8
|
+
Its main clients are the HL7 Java tools (`org.hl7.fhir.utilities.npm.PackageClient`,
|
|
9
|
+
used by the IG Publisher and validator). Package documents, tarball URLs and search
|
|
10
|
+
results follow npm's formats, so npm clients can point `--registry` at
|
|
11
|
+
`<server>/packages`.
|
|
12
|
+
|
|
13
|
+
## API
|
|
14
|
+
|
|
15
|
+
The public API is described by an OpenAPI 3.1 spec:
|
|
16
|
+
|
|
17
|
+
| URL | |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `/packages/openapi` | Browsable reference (HTML), with a "try it" form for each GET operation |
|
|
20
|
+
| `/packages/openapi.json` | The spec as JSON |
|
|
21
|
+
| `/packages/openapi.yaml` | The spec as YAML (the source file, [openapi.yaml](openapi.yaml)) |
|
|
22
|
+
|
|
23
|
+
Every response from the package server carries a
|
|
24
|
+
`Link: </packages/openapi.json>; rel="service-desc"` header (RFC 8631), and the
|
|
25
|
+
HTML pages carry the matching `<link>` element.
|
|
26
|
+
|
|
27
|
+
In summary:
|
|
28
|
+
|
|
29
|
+
| Endpoint | Purpose |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `GET /packages/catalog` | Search packages: `name`, `canonical`, `pkgcanonical`, `fhirversion`, `dependency`, `dependson`, `sort` |
|
|
32
|
+
| `GET /packages/-/v1/search` | npm search API: `text`, `size`, `from`, plus the `/catalog` parameters |
|
|
33
|
+
| `GET /packages/{id}` | npm package document: all versions, dependencies, tarball URLs |
|
|
34
|
+
| `GET /packages/{id}/{version}` | Download a package tarball |
|
|
35
|
+
| `GET /packages/updates` | Versions published since a date |
|
|
36
|
+
| `GET /packages/broken` | Package versions whose dependencies this registry doesn't hold |
|
|
37
|
+
|
|
38
|
+
Most endpoints return an HTML page instead of JSON when the request's `Accept`
|
|
39
|
+
header contains `text/html`. The JSON is the contract; the HTML is for people.
|
|
40
|
+
|
|
41
|
+
Query parameters are validated strictly. An unknown parameter, a repeated parameter,
|
|
42
|
+
or a value that's too long or badly formed is a 400, not silently ignored.
|
|
43
|
+
|
|
44
|
+
Not in the spec, deliberately:
|
|
45
|
+
|
|
46
|
+
* Operational pages: `/packages/stats`, `/packages/log` (the last crawler run),
|
|
47
|
+
`/packages/status`.
|
|
48
|
+
* `POST /packages/crawl`, which runs a full crawl on demand. It requires the
|
|
49
|
+
`x-crawl-token` header to match the `crawlToken` setting, and is disabled when no
|
|
50
|
+
token is configured.
|
|
51
|
+
|
|
52
|
+
### Keeping the spec honest
|
|
53
|
+
|
|
54
|
+
[openapi.yaml](openapi.yaml) is maintained by hand. `tests/packages/openapi.test.js`
|
|
55
|
+
fails if:
|
|
56
|
+
|
|
57
|
+
* a route is added to the router without being described in the spec, or listed in
|
|
58
|
+
the test's `EXCLUDED` table with a reason;
|
|
59
|
+
* the spec describes a route the router doesn't have;
|
|
60
|
+
* a query or path parameter's name, length limit, pattern or default differs from the
|
|
61
|
+
validation rules in `packages.js` (`SEARCH_PARAMS`, `V1_SEARCH_PARAMS`,
|
|
62
|
+
`UPDATES_PARAMS`, `BROKEN_PARAMS`, `PATH_PARAMS`).
|
|
63
|
+
|
|
64
|
+
So when you change a route or a parameter rule, change `openapi.yaml` with it.
|
|
65
|
+
`tests/packages/search.test.js` covers the search behaviour itself against an
|
|
66
|
+
in-memory database.
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
In the server config, under `modules.packages`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
"packages": {
|
|
74
|
+
"enabled": true,
|
|
75
|
+
"database": "packages.db",
|
|
76
|
+
"mirrorPath": "/absolute/path/to/mirror",
|
|
77
|
+
"crawlToken": "a-long-random-secret",
|
|
78
|
+
"crawler": {
|
|
79
|
+
"enabled": true,
|
|
80
|
+
"schedule": "0 * * * *"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Setting | |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `database` | The SQLite database. A relative path is resolved against the data directory's `packages` folder. Created on first run. |
|
|
88
|
+
| `mirrorPath` | Directory where the crawler saves a copy of each tarball, as `{id}-{version}.tgz`. A scoped id `@scope/name` is saved as `$scope$name`. |
|
|
89
|
+
| `bucketPath` | Optional. If set, downloads redirect to `{bucketPath}/{file}` and tarball URLs point there, instead of being served from the database. The file names are the mirror's, so the bucket is expected to be a copy of the mirror. |
|
|
90
|
+
| `baseUrl` | Optional. The public base URL used when building package URLs. Defaults to the request's host. |
|
|
91
|
+
| `crawlToken` | Shared secret for `POST /packages/crawl`. Omit it to disable that endpoint; the scheduled crawler is unaffected. |
|
|
92
|
+
| `crawler.enabled` | Run the crawler at startup and on the schedule. |
|
|
93
|
+
| `crawler.schedule` | Cron expression for crawls. |
|
|
94
|
+
| `masterUrl` | The list of feeds to crawl. Defaults to `https://fhir.github.io/ig-registry/package-feeds.json`. |
|
|
95
|
+
| `localFeedDirs` | Optional. Directories feeds may be read from as local files (for testing). |
|
|
96
|
+
| `allowPrivateAddresses` | Optional, for testing only. Lets the crawler fetch from private and loopback addresses, which it otherwise refuses (SSRF protection). |
|
|
97
|
+
|
|
98
|
+
## Crawling
|
|
99
|
+
|
|
100
|
+
Each crawl reads the master feed list, then each RSS feed in it (Simplifier last).
|
|
101
|
+
For each item, it downloads the package, checks the id, version and canonical, and
|
|
102
|
+
stores it.
|
|
103
|
+
|
|
104
|
+
* The master list's `package-restrictions` say which feeds may publish which package
|
|
105
|
+
ids. A package from a feed that isn't allowed to publish it is skipped.
|
|
106
|
+
* Items marked `notForPublication` are skipped.
|
|
107
|
+
* Feeds can be paginated (RFC 5005 `rel="next"`). The first page is read on every
|
|
108
|
+
crawl; older pages are read once and remembered in the `FeedPages` table.
|
|
109
|
+
* A feed that rate-limits (HTTP 429) is abandoned for that crawl and retried next
|
|
110
|
+
time.
|
|
111
|
+
|
|
112
|
+
`/packages/log` shows what the last crawl did, feed by feed and item by item.
|
|
113
|
+
|
|
114
|
+
## Storage
|
|
115
|
+
|
|
116
|
+
Everything is in the SQLite database. `PackageVersions` holds each version's
|
|
117
|
+
metadata and the tarball itself. `Packages` holds one row per package id, with its
|
|
118
|
+
current version and download count. `PackageDependencies` (stored as `id@version`),
|
|
119
|
+
`PackageFHIRVersions` and `PackageURLs` (the canonical URLs of the resources in each
|
|
120
|
+
version) support search.
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
<a href="/publisher" style="color: gold">Publisher Home</a> |
|
|
92
92
|
<a href="/publisher/tasks" style="color: gold">Tasks</a> |
|
|
93
93
|
<form method="[%loginAction%]" action="/publisher/[%loginPath%]" style="display:inline">
|
|
94
|
+
<input type="hidden" name="_csrf" value="[%csrfToken%]"/>
|
|
94
95
|
<button type="submit" style="background:none; border:none; color:gold; cursor:pointer; font:inherit; padding:0">[%loginTitle%]</button>
|
|
95
96
|
</form>
|
|
96
97
|
</div> <!-- /container -->
|
package/publisher/publisher.js
CHANGED
|
@@ -7,10 +7,12 @@ const githubRelease = require('./github-release');
|
|
|
7
7
|
const Database = require('sqlite3').Database;
|
|
8
8
|
const bcrypt = require('bcrypt');
|
|
9
9
|
const session = require('express-session');
|
|
10
|
+
const lusca = require('lusca');
|
|
10
11
|
const rateLimit = require('express-rate-limit');
|
|
11
12
|
const crypto = require('crypto');
|
|
12
13
|
const folders = require('../library/folder-setup');
|
|
13
14
|
const escape = require('escape-html');
|
|
15
|
+
const { requireSameOrigin } = require('../library/same-origin');
|
|
14
16
|
const {Utilities} = require("../library/utilities");
|
|
15
17
|
|
|
16
18
|
// GitHub refuses any file larger than this at the pre-receive hook, and the rejection takes down
|
|
@@ -59,14 +61,29 @@ class PublisherModule {
|
|
|
59
61
|
// plain HTTP - without it the browser will not send the cookie back and login
|
|
60
62
|
// appears to succeed and then silently do nothing.
|
|
61
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,
|
|
62
69
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
|
63
70
|
}
|
|
64
71
|
// Not using SQLiteStore to avoid the database conflict
|
|
65
72
|
}));
|
|
66
73
|
|
|
74
|
+
// every POST must come from one of our own pages - see library/same-origin.js
|
|
75
|
+
this.router.use(requireSameOrigin());
|
|
76
|
+
|
|
67
77
|
// Parse form data
|
|
68
78
|
this.router.use(express.urlencoded({ extended: true }));
|
|
69
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
|
+
|
|
70
87
|
// Set up routes
|
|
71
88
|
this.setupRoutes();
|
|
72
89
|
|
|
@@ -308,7 +325,7 @@ class PublisherModule {
|
|
|
308
325
|
|
|
309
326
|
setupRoutes() {
|
|
310
327
|
// Main dashboard
|
|
311
|
-
this.router.get('/', this.renderDashboard.bind(this));
|
|
328
|
+
this.router.get('/', this.csrfIfLoggedIn, this.renderDashboard.bind(this));
|
|
312
329
|
|
|
313
330
|
// Authentication. The login post is rate limited: besides slowing down guessing, it
|
|
314
331
|
// is the one unauthenticated route that runs bcrypt, and bcrypt.compare holds a libuv
|
|
@@ -322,20 +339,39 @@ class PublisherModule {
|
|
|
322
339
|
legacyHeaders: false
|
|
323
340
|
});
|
|
324
341
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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));
|
|
328
364
|
|
|
329
365
|
// Tasks
|
|
330
|
-
this.router.get('/tasks', this.renderTasks.bind(this));
|
|
331
|
-
this.router.post('/tasks', this.requireAuth.bind(this), this.createTask.bind(this));
|
|
332
|
-
this.router.post('/tasks/:id/approve', this.requireAuth.bind(this), this.approveTask.bind(this));
|
|
333
|
-
this.router.post('/tasks/:id/delete', this.requireAuth.bind(this), this.deleteTask.bind(this));
|
|
334
|
-
this.router.post('/tasks/:id/retry', this.requireAuth.bind(this), this.retryTask.bind(this));
|
|
335
|
-
this.router.get('/tasks/:id/output', this.getTaskOutput.bind(this));
|
|
336
|
-
this.router.get('/tasks/:id/history', this.getTaskHistory.bind(this));
|
|
337
|
-
this.router.get('/tasks/:id/qa', this.getTaskQA.bind(this));
|
|
338
|
-
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) => {
|
|
339
375
|
const taskId = req.params.id;
|
|
340
376
|
this.getTask(taskId).then(task => {
|
|
341
377
|
if (!task || !task.local_folder) {
|
|
@@ -347,13 +383,27 @@ class PublisherModule {
|
|
|
347
383
|
});
|
|
348
384
|
|
|
349
385
|
// Admin routes
|
|
350
|
-
this.router.get('/admin/websites', this.requireAdmin.bind(this), this.renderWebsites.bind(this));
|
|
351
|
-
this.router.post('/admin/websites', this.requireAdmin.bind(this), this.createWebsite.bind(this));
|
|
352
|
-
this.router.get('/admin/websites/:id/edit', this.requireAdmin.bind(this), this.renderEditWebsite.bind(this));
|
|
353
|
-
this.router.post('/admin/websites/:id/edit', this.requireAdmin.bind(this), this.updateWebsite.bind(this));
|
|
354
|
-
this.router.get('/admin/users', this.requireAdmin.bind(this), this.renderUsers.bind(this));
|
|
355
|
-
this.router.post('/admin/users', this.requireAdmin.bind(this), this.createUser.bind(this));
|
|
356
|
-
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) + '">' : '';
|
|
357
407
|
}
|
|
358
408
|
|
|
359
409
|
// Background Task Processing
|
|
@@ -1476,7 +1526,7 @@ class PublisherModule {
|
|
|
1476
1526
|
content += '<a href="/publisher/admin/websites" class="btn btn-secondary me-2">Manage Websites</a>';
|
|
1477
1527
|
content += '<a href="/publisher/admin/users" class="btn btn-secondary">Manage Users</a>';
|
|
1478
1528
|
}
|
|
1479
|
-
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);
|
|
1480
1530
|
content += '<button type="submit" class="btn btn-outline-secondary">' + escape(req.session.userName) + ' \u2014 Logout</button>';
|
|
1481
1531
|
content += '</form>';
|
|
1482
1532
|
content += '</div>';
|
|
@@ -1517,7 +1567,7 @@ class PublisherModule {
|
|
|
1517
1567
|
templateVars: {
|
|
1518
1568
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
1519
1569
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
1520
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1570
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
1521
1571
|
}
|
|
1522
1572
|
});
|
|
1523
1573
|
|
|
@@ -1541,7 +1591,7 @@ class PublisherModule {
|
|
|
1541
1591
|
let content = '<div class="row justify-content-center">';
|
|
1542
1592
|
content += '<div class="col-md-6">';
|
|
1543
1593
|
content += '<h3>Login</h3>';
|
|
1544
|
-
content += '<form method="post" action="/publisher/login">';
|
|
1594
|
+
content += '<form method="post" action="/publisher/login">' + this.csrfField(res);
|
|
1545
1595
|
content += '<div class="mb-3">';
|
|
1546
1596
|
content += '<label for="login" class="form-label">Username</label>';
|
|
1547
1597
|
content += '<input type="text" class="form-control" id="login" name="login" required>';
|
|
@@ -1559,7 +1609,7 @@ class PublisherModule {
|
|
|
1559
1609
|
templateVars: {
|
|
1560
1610
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
1561
1611
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
1562
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1612
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
1563
1613
|
}});
|
|
1564
1614
|
res.setHeader('Content-Type', 'text/html');
|
|
1565
1615
|
res.send(html);
|
|
@@ -1595,6 +1645,9 @@ class PublisherModule {
|
|
|
1595
1645
|
return res.redirect('/publisher/login?error=invalid');
|
|
1596
1646
|
}
|
|
1597
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()));
|
|
1598
1651
|
req.session.userId = user.id;
|
|
1599
1652
|
req.session.userName = user.name;
|
|
1600
1653
|
req.session.isAdmin = user.is_admin;
|
|
@@ -1640,7 +1693,7 @@ class PublisherModule {
|
|
|
1640
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>';
|
|
1641
1694
|
content += '<div id="create-task-panel" style="display: none;" class="mt-3">';
|
|
1642
1695
|
content += '<h3>Create New Publication Task</h3>';
|
|
1643
|
-
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);
|
|
1644
1697
|
content += '<div class="col-md-3">';
|
|
1645
1698
|
content += '<label for="website_id" class="form-label">Target Website</label>';
|
|
1646
1699
|
content += '<select class="form-select" id="website_id" name="website_id" required>';
|
|
@@ -1718,7 +1771,7 @@ class PublisherModule {
|
|
|
1718
1771
|
content += '<a href="/publisher/tasks/' + task.id + '/qa-files/index.html" class="btn btn-sm btn-outline-secondary me-1">View IG</a>';
|
|
1719
1772
|
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-sm btn-outline-secondary me-1">View QA</a>';
|
|
1720
1773
|
if (canApprove) {
|
|
1721
|
-
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);
|
|
1722
1775
|
content += '<button type="submit" name="approve" class="btn btn-sm btn-success me-1">Approve</button>';
|
|
1723
1776
|
content += '</form>';
|
|
1724
1777
|
}
|
|
@@ -1735,13 +1788,13 @@ class PublisherModule {
|
|
|
1735
1788
|
}
|
|
1736
1789
|
|
|
1737
1790
|
if (canDelete) {
|
|
1738
|
-
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);
|
|
1739
1792
|
content += '<button type="submit" class="btn btn-sm btn-danger">Delete</button>';
|
|
1740
1793
|
content += '</form>';
|
|
1741
1794
|
}
|
|
1742
1795
|
|
|
1743
1796
|
if (req.session.userId && task.status === 'failed') {
|
|
1744
|
-
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);
|
|
1745
1798
|
content += '<button type="submit" class="btn btn-sm btn-warning">Retry</button>';
|
|
1746
1799
|
content += '</form>';
|
|
1747
1800
|
}
|
|
@@ -1761,7 +1814,7 @@ class PublisherModule {
|
|
|
1761
1814
|
templateVars: {
|
|
1762
1815
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
1763
1816
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
1764
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1817
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
1765
1818
|
}});
|
|
1766
1819
|
res.setHeader('Content-Type', 'text/html');
|
|
1767
1820
|
res.send(html);
|
|
@@ -1951,7 +2004,7 @@ class PublisherModule {
|
|
|
1951
2004
|
res.redirect('/publisher/tasks');
|
|
1952
2005
|
} catch (error) {
|
|
1953
2006
|
this.logger.error('Error deleting task:', error);
|
|
1954
|
-
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));
|
|
1955
2008
|
}
|
|
1956
2009
|
} finally {
|
|
1957
2010
|
this.stats.countRequest('delete-task', Date.now() - start);
|
|
@@ -1982,7 +2035,7 @@ class PublisherModule {
|
|
|
1982
2035
|
res.redirect('/publisher/tasks/' + newTaskId + '/history');
|
|
1983
2036
|
} catch (error) {
|
|
1984
2037
|
this.logger.error('Error retrying task:', error);
|
|
1985
|
-
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));
|
|
1986
2039
|
}
|
|
1987
2040
|
} finally {
|
|
1988
2041
|
this.stats.countRequest('retry-task', Date.now() - start);
|
|
@@ -2093,7 +2146,7 @@ class PublisherModule {
|
|
|
2093
2146
|
templateVars: {
|
|
2094
2147
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
2095
2148
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
2096
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
2149
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
2097
2150
|
}});
|
|
2098
2151
|
res.setHeader('Content-Type', 'text/html');
|
|
2099
2152
|
res.send(html);
|
|
@@ -2310,7 +2363,7 @@ class PublisherModule {
|
|
|
2310
2363
|
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-outline-secondary me-2">View QA Report</a>';
|
|
2311
2364
|
}
|
|
2312
2365
|
if (req.session.userId && task.status === 'failed') {
|
|
2313
|
-
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);
|
|
2314
2367
|
content += '<button type="submit" class="btn btn-warning">Retry</button>';
|
|
2315
2368
|
content += '</form>';
|
|
2316
2369
|
}
|
|
@@ -2324,7 +2377,7 @@ class PublisherModule {
|
|
|
2324
2377
|
(detailIsPostApprovalFailed && req.session.isAdmin)
|
|
2325
2378
|
);
|
|
2326
2379
|
if (detailCanDelete) {
|
|
2327
|
-
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);
|
|
2328
2381
|
content += '<button type="submit" class="btn btn-danger">Delete</button>';
|
|
2329
2382
|
content += '</form>';
|
|
2330
2383
|
}
|
|
@@ -2335,7 +2388,7 @@ class PublisherModule {
|
|
|
2335
2388
|
templateVars: {
|
|
2336
2389
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
2337
2390
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
2338
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
2391
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
2339
2392
|
}});
|
|
2340
2393
|
res.setHeader('Content-Type', 'text/html');
|
|
2341
2394
|
res.send(html);
|
|
@@ -2356,7 +2409,7 @@ class PublisherModule {
|
|
|
2356
2409
|
if (!website) return res.status(404).send('Website not found');
|
|
2357
2410
|
|
|
2358
2411
|
let content = '<h3>Edit Website</h3>';
|
|
2359
|
-
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);
|
|
2360
2413
|
content += '<div class="col-md-4"><label class="form-label">Website Name</label>';
|
|
2361
2414
|
content += '<input type="text" class="form-control" name="name" value="' + escape(website.name) + '" required></div>';
|
|
2362
2415
|
content += '<div class="col-md-4"><label class="form-label">Local Folder</label>';
|
|
@@ -2376,7 +2429,7 @@ class PublisherModule {
|
|
|
2376
2429
|
content += '</form>';
|
|
2377
2430
|
|
|
2378
2431
|
const html = htmlServer.renderPage('publisher', 'Edit Website - FHIR Publisher', content, {
|
|
2379
|
-
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 || '' }
|
|
2380
2433
|
});
|
|
2381
2434
|
res.setHeader('Content-Type', 'text/html');
|
|
2382
2435
|
res.send(html);
|
|
@@ -2427,7 +2480,7 @@ class PublisherModule {
|
|
|
2427
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>';
|
|
2428
2481
|
content += '<div id="add-website-panel" style="display: none;" class="mt-3">';
|
|
2429
2482
|
content += '<h3>Add New Website</h3>';
|
|
2430
|
-
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);
|
|
2431
2484
|
content += '<div class="col-md-4">';
|
|
2432
2485
|
content += '<label for="name" class="form-label">Website Name</label>';
|
|
2433
2486
|
content += '<input type="text" class="form-control" id="name" name="name" required>';
|
|
@@ -2496,7 +2549,7 @@ class PublisherModule {
|
|
|
2496
2549
|
templateVars: {
|
|
2497
2550
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
2498
2551
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
2499
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
2552
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
2500
2553
|
}});
|
|
2501
2554
|
res.setHeader('Content-Type', 'text/html');
|
|
2502
2555
|
res.send(html);
|
|
@@ -2556,7 +2609,7 @@ class PublisherModule {
|
|
|
2556
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>';
|
|
2557
2610
|
content += '<div id="add-user-panel" style="display: none;" class="mt-3">';
|
|
2558
2611
|
content += '<h3>Add New User</h3>';
|
|
2559
|
-
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);
|
|
2560
2613
|
content += '<div class="col-md-3">';
|
|
2561
2614
|
content += '<label for="name" class="form-label">Full Name</label>';
|
|
2562
2615
|
content += '<input type="text" class="form-control" id="name" name="name" required>';
|
|
@@ -2601,7 +2654,7 @@ class PublisherModule {
|
|
|
2601
2654
|
if (websites.length === 0) {
|
|
2602
2655
|
content += '<p>No websites available for permission assignment.</p>';
|
|
2603
2656
|
} else {
|
|
2604
|
-
content += '<form method="post" action="/publisher/admin/permissions">';
|
|
2657
|
+
content += '<form method="post" action="/publisher/admin/permissions">' + this.csrfField(res);
|
|
2605
2658
|
content += '<input type="hidden" name="user_id" value="' + user.id + '">';
|
|
2606
2659
|
content += '<div class="permission-grid">';
|
|
2607
2660
|
content += '<div><strong>Website</strong></div>';
|
|
@@ -2638,7 +2691,7 @@ class PublisherModule {
|
|
|
2638
2691
|
templateVars: {
|
|
2639
2692
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
2640
2693
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
2641
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
2694
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
2642
2695
|
}});
|
|
2643
2696
|
res.setHeader('Content-Type', 'text/html');
|
|
2644
2697
|
res.send(html);
|