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.
Files changed (73) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +3 -2
  3. package/config-template.json +20 -0
  4. package/library/fhir-openapi-schema.js +441 -0
  5. package/library/html-server.js +9 -3
  6. package/library/languages.js +15 -0
  7. package/library/npm-audit.js +265 -0
  8. package/library/openapi-doc.js +557 -0
  9. package/library/same-origin.js +62 -0
  10. package/library/utilities.js +21 -0
  11. package/nginx.md +17 -9
  12. package/package.json +10 -4
  13. package/packages/openapi.js +13 -0
  14. package/packages/openapi.yaml +603 -0
  15. package/packages/packages-template.html +4 -2
  16. package/packages/packages.js +286 -81
  17. package/packages/readme.md +120 -0
  18. package/publisher/publisher-template.html +1 -0
  19. package/publisher/publisher.js +95 -42
  20. package/registry/api.js +117 -41
  21. package/registry/crawler.js +24 -0
  22. package/registry/fhirsmith-releases.js +170 -0
  23. package/registry/model.js +10 -0
  24. package/registry/openapi.js +13 -0
  25. package/registry/openapi.yaml +311 -0
  26. package/registry/readme.md +107 -208
  27. package/registry/registry-template.html +4 -1
  28. package/registry/registry.js +183 -20
  29. package/server.js +25 -6
  30. package/testing/openapi-schemas.config.js +58 -0
  31. package/testing/openapi-schemas.json +1920 -0
  32. package/testing/openapi.js +15 -0
  33. package/testing/openapi.yaml +273 -0
  34. package/testing/readme.md +75 -1
  35. package/testing/render.js +270 -29
  36. package/testing/search.js +1 -1
  37. package/testing/store.js +152 -8
  38. package/testing/testing-template.html +2 -0
  39. package/testing/testing.js +307 -9
  40. package/translations/Messages.properties +1 -0
  41. package/tx/README.md +58 -0
  42. package/tx/cm/cm-database.js +10 -1
  43. package/tx/cm/cm-package.js +8 -1
  44. package/tx/cs/cs-rxnorm.js +23 -6
  45. package/tx/cs/cs-snomed.js +3 -72
  46. package/tx/html/tx-template.html +2 -0
  47. package/tx/library/canonical-resource.js +50 -1
  48. package/tx/library/codesystem.js +1 -0
  49. package/tx/library/conceptmap.js +1 -0
  50. package/tx/library/valueset.js +16 -0
  51. package/tx/library.js +17 -2
  52. package/tx/openapi-operations.js +363 -0
  53. package/tx/openapi-schemas.config.js +118 -0
  54. package/tx/openapi-schemas.json +7903 -0
  55. package/tx/openapi.js +289 -0
  56. package/tx/openapi.yaml +151 -0
  57. package/tx/operation-context.js +15 -0
  58. package/tx/params.js +3 -12
  59. package/tx/provider.js +10 -1
  60. package/tx/sct/editions.js +70 -0
  61. package/tx/tx-html.js +6 -0
  62. package/tx/tx.js +55 -0
  63. package/tx/vs/vs-database.js +14 -5
  64. package/tx/vs/vs-package.js +10 -0
  65. package/tx/workers/expand.js +37 -18
  66. package/tx/workers/metadata.js +0 -13
  67. package/tx/workers/translate.js +25 -1
  68. package/tx/workers/validate.js +74 -24
  69. package/tx/workers/worker.js +32 -43
  70. package/utilities/extract-schema-json.js +167 -0
  71. package/utilities/generate-openapi-schemas.js +49 -0
  72. package/utilities/submit-test-report.js +1 -1
  73. 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> &nbsp;|&nbsp;
92
92
  <a href="/publisher/tasks" style="color: gold">Tasks</a> &nbsp;|&nbsp;
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 -->
@@ -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
- this.router.get('/login', this.renderLogin.bind(this));
326
- this.router.post('/login', loginLimiter, this.handleLogin.bind(this));
327
- this.router.post('/logout', this.handleLogout.bind(this));
342
+ // Task actions delete build output from disk or queue new builds: a logged-in user can't
343
+ // be allowed to hammer them either
344
+ const taskActionLimiter = rateLimit({
345
+ windowMs: 60 * 1000,
346
+ max: this.config.taskActionsPerMinute ?? 30,
347
+ message: 'Too many task actions, please try again later.',
348
+ standardHeaders: true,
349
+ legacyHeaders: false
350
+ });
351
+
352
+ // the task pages read build logs and QA output from disk
353
+ const taskReadLimiter = rateLimit({
354
+ windowMs: 60 * 1000,
355
+ max: this.config.taskReadsPerMinute ?? 300,
356
+ message: 'Too many requests, please try again later.',
357
+ standardHeaders: true,
358
+ legacyHeaders: false
359
+ });
360
+
361
+ this.router.get('/login', this.csrf, this.renderLogin.bind(this));
362
+ this.router.post('/login', loginLimiter, this.csrf, this.handleLogin.bind(this));
363
+ this.router.post('/logout', this.csrf, this.handleLogout.bind(this));
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);