fhirsmith 0.3.0

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 (277) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/FHIRsmith.png +0 -0
  3. package/README.md +277 -0
  4. package/config-template.json +144 -0
  5. package/library/folder-setup.js +58 -0
  6. package/library/html-server.js +166 -0
  7. package/library/html.js +835 -0
  8. package/library/i18nsupport.js +259 -0
  9. package/library/languages.js +779 -0
  10. package/library/logger-telnet.js +205 -0
  11. package/library/logger.js +279 -0
  12. package/library/package-manager.js +876 -0
  13. package/library/utilities.js +196 -0
  14. package/library/version-utilities.js +1056 -0
  15. package/npmprojector/config-example.json +13 -0
  16. package/npmprojector/indexer.js +394 -0
  17. package/npmprojector/npmprojector.js +395 -0
  18. package/npmprojector/readme.md +174 -0
  19. package/npmprojector/watcher.js +335 -0
  20. package/package.json +119 -0
  21. package/packages/package-crawler.js +846 -0
  22. package/packages/packages-template.html +126 -0
  23. package/packages/packages.js +2838 -0
  24. package/passwords.ini +2 -0
  25. package/publisher/publisher-template.html +208 -0
  26. package/publisher/publisher.js +2167 -0
  27. package/publisher/task-draft.js +458 -0
  28. package/registry/api.js +735 -0
  29. package/registry/crawler.js +637 -0
  30. package/registry/model.js +513 -0
  31. package/registry/readme.md +243 -0
  32. package/registry/registry-data.json +121015 -0
  33. package/registry/registry-template.html +126 -0
  34. package/registry/registry.js +1395 -0
  35. package/registry/test-runner.js +237 -0
  36. package/root-template.html +124 -0
  37. package/server.js +524 -0
  38. package/shl/private-key.pem +5 -0
  39. package/shl/public-key.pem +18 -0
  40. package/shl/shl.js +1125 -0
  41. package/shl/vhl.js +69 -0
  42. package/static/FHIRsmith128.png +0 -0
  43. package/static/FHIRsmith16.png +0 -0
  44. package/static/FHIRsmith32.png +0 -0
  45. package/static/FHIRsmith64.png +0 -0
  46. package/static/assets/css/bootstrap-fhir.css +5302 -0
  47. package/static/assets/css/bootstrap-glyphicons.css +2 -0
  48. package/static/assets/css/bootstrap.css +4097 -0
  49. package/static/assets/css/jquery-ui.css +523 -0
  50. package/static/assets/css/jquery-ui.structure.css +863 -0
  51. package/static/assets/css/jquery-ui.structure.min.css +5 -0
  52. package/static/assets/css/jquery-ui.theme.css +439 -0
  53. package/static/assets/css/jquery-ui.theme.min.css +5 -0
  54. package/static/assets/css/jquery.ui.all.css +7 -0
  55. package/static/assets/css/modules.css +18 -0
  56. package/static/assets/css/project.css +367 -0
  57. package/static/assets/css/pygments-manni.css +66 -0
  58. package/static/assets/css/tags.css +74 -0
  59. package/static/assets/css/xml.css +2 -0
  60. package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
  61. package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
  62. package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
  63. package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
  64. package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
  65. package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
  66. package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
  67. package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
  68. package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
  69. package/static/assets/ico/favicon.ico +0 -0
  70. package/static/assets/ico/favicon.png +0 -0
  71. package/static/assets/images/fhir-logo-www.png +0 -0
  72. package/static/assets/images/fhir-logo.png +0 -0
  73. package/static/assets/images/hl7-logo.png +0 -0
  74. package/static/assets/images/logo_ansinew.jpg +0 -0
  75. package/static/assets/images/search.png +0 -0
  76. package/static/assets/images/stripe.png +0 -0
  77. package/static/assets/images/target.png +0 -0
  78. package/static/assets/images/tx-registry-root.gif +0 -0
  79. package/static/assets/images/tx-registry.png +0 -0
  80. package/static/assets/images/tx-server.png +0 -0
  81. package/static/assets/images/tx-version.png +0 -0
  82. package/static/assets/js/bootstrap.min.js +6 -0
  83. package/static/assets/js/fhir-gw.js +259 -0
  84. package/static/assets/js/fhir.js +2 -0
  85. package/static/assets/js/html5shiv.js +8 -0
  86. package/static/assets/js/jcookie.js +96 -0
  87. package/static/assets/js/jquery-ui.min.js +6 -0
  88. package/static/assets/js/jquery.js +10716 -0
  89. package/static/assets/js/jquery.min.js +2 -0
  90. package/static/assets/js/jquery.ui.core.js +314 -0
  91. package/static/assets/js/jquery.ui.draggable.js +825 -0
  92. package/static/assets/js/jquery.ui.mouse.js +162 -0
  93. package/static/assets/js/jquery.ui.resizable.js +842 -0
  94. package/static/assets/js/jquery.ui.widget.js +268 -0
  95. package/static/assets/js/json2.js +487 -0
  96. package/static/assets/js/jtip.js +97 -0
  97. package/static/assets/js/respond.min.js +6 -0
  98. package/static/assets/js/statuspage.js +70 -0
  99. package/static/assets/js/xml.js +2 -0
  100. package/static/dist/js/bootstrap.js +1964 -0
  101. package/static/favicon.png +0 -0
  102. package/static/fhir.css +626 -0
  103. package/static/icon-fhir-16.png +0 -0
  104. package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
  105. package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
  106. package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
  107. package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
  108. package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
  109. package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  110. package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
  111. package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
  112. package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
  113. package/static/images/ui-icons_222222_256x240.png +0 -0
  114. package/static/images/ui-icons_228ef1_256x240.png +0 -0
  115. package/static/images/ui-icons_ef8c08_256x240.png +0 -0
  116. package/static/images/ui-icons_ffd27a_256x240.png +0 -0
  117. package/static/images/ui-icons_ffffff_256x240.png +0 -0
  118. package/static/js/jquery.effects.blind.js +49 -0
  119. package/static/js/jquery.effects.bounce.js +78 -0
  120. package/static/js/jquery.effects.clip.js +54 -0
  121. package/static/js/jquery.effects.core.js +763 -0
  122. package/static/js/jquery.effects.drop.js +50 -0
  123. package/static/js/jquery.effects.explode.js +79 -0
  124. package/static/js/jquery.effects.fade.js +32 -0
  125. package/static/js/jquery.effects.fold.js +56 -0
  126. package/static/js/jquery.effects.highlight.js +50 -0
  127. package/static/js/jquery.effects.pulsate.js +51 -0
  128. package/static/js/jquery.effects.scale.js +178 -0
  129. package/static/js/jquery.effects.shake.js +57 -0
  130. package/static/js/jquery.effects.slide.js +50 -0
  131. package/static/js/jquery.effects.transfer.js +45 -0
  132. package/static/js/jquery.ui.accordion.js +611 -0
  133. package/static/js/jquery.ui.autocomplete.js +612 -0
  134. package/static/js/jquery.ui.button.js +416 -0
  135. package/static/js/jquery.ui.datepicker.js +1823 -0
  136. package/static/js/jquery.ui.dialog.js +878 -0
  137. package/static/js/jquery.ui.droppable.js +296 -0
  138. package/static/js/jquery.ui.position.js +252 -0
  139. package/static/js/jquery.ui.progressbar.js +109 -0
  140. package/static/js/jquery.ui.selectable.js +266 -0
  141. package/static/js/jquery.ui.slider.js +666 -0
  142. package/static/js/jquery.ui.sortable.js +1077 -0
  143. package/static/js/jquery.ui.tabs.js +758 -0
  144. package/stats.js +80 -0
  145. package/test-cache/vsac/vsac-valuesets.db +0 -0
  146. package/token/nginx_passport_setup.md +383 -0
  147. package/token/security_guide.md +294 -0
  148. package/token/token-template.html +330 -0
  149. package/token/token.js +1300 -0
  150. package/translations/Messages.properties +1510 -0
  151. package/translations/Messages_ar.properties +1399 -0
  152. package/translations/Messages_de.properties +836 -0
  153. package/translations/Messages_es.properties +737 -0
  154. package/translations/Messages_fr.properties +1 -0
  155. package/translations/Messages_ja.properties +893 -0
  156. package/translations/Messages_nl.properties +1357 -0
  157. package/translations/Messages_pt.properties +1302 -0
  158. package/translations/Messages_ru.properties +1 -0
  159. package/translations/Messages_uz.properties +1 -0
  160. package/translations/Messages_zh.properties +1 -0
  161. package/translations/rendering-phrases.properties +1128 -0
  162. package/translations/rendering-phrases_ar.properties +1091 -0
  163. package/translations/rendering-phrases_de.properties +6 -0
  164. package/translations/rendering-phrases_es.properties +6 -0
  165. package/translations/rendering-phrases_fr.properties +624 -0
  166. package/translations/rendering-phrases_ja.properties +21 -0
  167. package/translations/rendering-phrases_nl.properties +970 -0
  168. package/translations/rendering-phrases_pt.properties +1020 -0
  169. package/translations/rendering-phrases_ru.properties +1094 -0
  170. package/translations/rendering-phrases_uz.properties +1 -0
  171. package/translations/rendering-phrases_zh.properties +1 -0
  172. package/tx/README.md +418 -0
  173. package/tx/cm/cm-api.js +110 -0
  174. package/tx/cm/cm-database.js +735 -0
  175. package/tx/cm/cm-package.js +325 -0
  176. package/tx/cs/cs-api.js +789 -0
  177. package/tx/cs/cs-areacode.js +615 -0
  178. package/tx/cs/cs-country.js +1110 -0
  179. package/tx/cs/cs-cpt.js +785 -0
  180. package/tx/cs/cs-cs.js +1579 -0
  181. package/tx/cs/cs-currency.js +539 -0
  182. package/tx/cs/cs-db.js +1321 -0
  183. package/tx/cs/cs-hgvs.js +329 -0
  184. package/tx/cs/cs-lang.js +465 -0
  185. package/tx/cs/cs-loinc.js +1485 -0
  186. package/tx/cs/cs-mimetypes.js +238 -0
  187. package/tx/cs/cs-ndc.js +704 -0
  188. package/tx/cs/cs-omop.js +1025 -0
  189. package/tx/cs/cs-provider-api.js +43 -0
  190. package/tx/cs/cs-provider-list.js +37 -0
  191. package/tx/cs/cs-rxnorm.js +808 -0
  192. package/tx/cs/cs-snomed.js +1102 -0
  193. package/tx/cs/cs-ucum.js +514 -0
  194. package/tx/cs/cs-unii.js +271 -0
  195. package/tx/cs/cs-uri.js +218 -0
  196. package/tx/cs/cs-usstates.js +305 -0
  197. package/tx/dev.fhir.org.yml +14 -0
  198. package/tx/fixtures/test-cases-setup.json +18 -0
  199. package/tx/fixtures/test-cases.yml +16 -0
  200. package/tx/html/codesystem-operations.liquid +25 -0
  201. package/tx/html/home-metrics.liquid +247 -0
  202. package/tx/html/operations-form.liquid +148 -0
  203. package/tx/html/search-form.liquid +62 -0
  204. package/tx/html/tx-template.html +133 -0
  205. package/tx/html/valueset-operations.liquid +54 -0
  206. package/tx/importers/atc-to-fhir.js +316 -0
  207. package/tx/importers/import-loinc.module.js +1536 -0
  208. package/tx/importers/import-ndc.module.js +1088 -0
  209. package/tx/importers/import-rxnorm.module.js +898 -0
  210. package/tx/importers/import-sct.module.js +2457 -0
  211. package/tx/importers/import-unii.module.js +601 -0
  212. package/tx/importers/readme.md +453 -0
  213. package/tx/importers/subset-loinc.module.js +1081 -0
  214. package/tx/importers/subset-rxnorm.module.js +938 -0
  215. package/tx/importers/tx-import-base.js +351 -0
  216. package/tx/importers/tx-import-settings.js +310 -0
  217. package/tx/importers/tx-import.js +357 -0
  218. package/tx/library/canonical-resource.js +88 -0
  219. package/tx/library/capabilitystatement.js +292 -0
  220. package/tx/library/codesystem.js +774 -0
  221. package/tx/library/conceptmap.js +568 -0
  222. package/tx/library/designations.js +932 -0
  223. package/tx/library/errors.js +77 -0
  224. package/tx/library/extensions.js +117 -0
  225. package/tx/library/namingsystem.js +322 -0
  226. package/tx/library/operation-outcome.js +127 -0
  227. package/tx/library/parameters.js +105 -0
  228. package/tx/library/renderer.js +1559 -0
  229. package/tx/library/terminologycapabilities.js +418 -0
  230. package/tx/library/ucum-parsers.js +1029 -0
  231. package/tx/library/ucum-service.js +370 -0
  232. package/tx/library/ucum-types.js +1099 -0
  233. package/tx/library/valueset.js +543 -0
  234. package/tx/library.js +676 -0
  235. package/tx/ocl/cm-ocl.js +106 -0
  236. package/tx/ocl/cs-ocl.js +39 -0
  237. package/tx/ocl/vs-ocl.js +105 -0
  238. package/tx/operation-context.js +568 -0
  239. package/tx/params.js +613 -0
  240. package/tx/provider.js +403 -0
  241. package/tx/sct/ecl.js +1560 -0
  242. package/tx/sct/expressions.js +2077 -0
  243. package/tx/sct/structures.js +1396 -0
  244. package/tx/tx-html.js +1063 -0
  245. package/tx/tx.fhir.org.yml +39 -0
  246. package/tx/tx.js +927 -0
  247. package/tx/vs/vs-api.js +112 -0
  248. package/tx/vs/vs-database.js +786 -0
  249. package/tx/vs/vs-package.js +358 -0
  250. package/tx/vs/vs-vsac.js +366 -0
  251. package/tx/workers/batch-validate.js +129 -0
  252. package/tx/workers/batch.js +361 -0
  253. package/tx/workers/closure.js +32 -0
  254. package/tx/workers/expand.js +1845 -0
  255. package/tx/workers/lookup.js +407 -0
  256. package/tx/workers/metadata.js +467 -0
  257. package/tx/workers/operations.js +34 -0
  258. package/tx/workers/read.js +164 -0
  259. package/tx/workers/search.js +384 -0
  260. package/tx/workers/subsumes.js +334 -0
  261. package/tx/workers/translate.js +492 -0
  262. package/tx/workers/validate.js +2504 -0
  263. package/tx/workers/worker.js +904 -0
  264. package/tx/xml/capabilitystatement-xml.js +63 -0
  265. package/tx/xml/codesystem-xml.js +62 -0
  266. package/tx/xml/conceptmap-xml.js +65 -0
  267. package/tx/xml/namingsystem-xml.js +65 -0
  268. package/tx/xml/operationoutcome-xml.js +127 -0
  269. package/tx/xml/parameters-xml.js +312 -0
  270. package/tx/xml/terminologycapabilities-xml.js +64 -0
  271. package/tx/xml/valueset-xml.js +64 -0
  272. package/tx/xml/xml-base.js +603 -0
  273. package/vcl/vcl-parser.js +1098 -0
  274. package/vcl/vcl.js +253 -0
  275. package/windows-install.js +19 -0
  276. package/xig/xig-template.html +124 -0
  277. package/xig/xig.js +3049 -0
package/shl/shl.js ADDED
@@ -0,0 +1,1125 @@
1
+ //
2
+ // Copyright 2025, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ const express = require('express');
8
+ const sqlite3 = require('sqlite3').verbose();
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const cron = require('node-cron');
12
+ const CBOR = require('cbor');
13
+ const pako = require('pako');
14
+ const base45 = require('base45');
15
+ const fs = require('fs');
16
+ const folders = require('../library/folder-setup');
17
+
18
+ const Logger = require('../library/logger');
19
+ const shlLog = Logger.getInstance().child({ module: 'shl' });
20
+
21
+ // Import the FHIR Validator
22
+ const FhirValidator = require('fhir-validator-wrapper');
23
+
24
+ // Try to load vhl.js module, but don't fail if it doesn't exist
25
+ let vhlProcessor;
26
+ try {
27
+ vhlProcessor = require('./vhl.js');
28
+ } catch (err) {
29
+ shlLog.warning('vhl.js not found - VHL processing will be skipped');
30
+ vhlProcessor = null;
31
+ }
32
+
33
+ class SHLModule {
34
+ constructor(stats) {
35
+ this.db = null;
36
+ this.config = null;
37
+ this.router = express.Router();
38
+ this.cleanupJob = null;
39
+ this.fhirValidator = null;
40
+ this.setupSecurityMiddleware();
41
+ this.setupRoutes();
42
+ this.stats = stats;
43
+ }
44
+
45
+ setupSecurityMiddleware() {
46
+ // Security headers middleware
47
+ this.router.use((req, res, next) => {
48
+ res.setHeader('X-Content-Type-Options', 'nosniff');
49
+ res.setHeader('X-Frame-Options', 'DENY');
50
+ res.setHeader('X-XSS-Protection', '1; mode=block');
51
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
52
+ res.setHeader('Content-Security-Policy', [
53
+ "default-src 'self'",
54
+ "script-src 'self' 'unsafe-inline'",
55
+ "style-src 'self' 'unsafe-inline'",
56
+ "img-src 'self' data: https:",
57
+ "font-src 'self'",
58
+ "connect-src 'self'",
59
+ "frame-ancestors 'none'"
60
+ ].join('; '));
61
+ res.removeHeader('X-Powered-By');
62
+ next();
63
+ });
64
+ }
65
+
66
+ // Parameter validation middleware
67
+ validateQueryParams(allowedParams = {}) {
68
+ return (req, res, next) => {
69
+ try {
70
+ const normalized = {};
71
+
72
+ for (const [key, value] of Object.entries(req.query)) {
73
+ if (Array.isArray(value)) {
74
+ return res.status(400).json({
75
+ error: 'Parameter pollution detected',
76
+ parameter: key
77
+ });
78
+ }
79
+
80
+ if (allowedParams[key]) {
81
+ const config = allowedParams[key];
82
+
83
+ if (value !== undefined) {
84
+ if (typeof value !== 'string') {
85
+ return res.status(400).json({
86
+ error: `Parameter ${key} must be a string`
87
+ });
88
+ }
89
+
90
+ if (value.length > (config.maxLength || 255)) {
91
+ return res.status(400).json({
92
+ error: `Parameter ${key} too long (max ${config.maxLength || 255})`
93
+ });
94
+ }
95
+
96
+ if (config.pattern && !config.pattern.test(value)) {
97
+ return res.status(400).json({
98
+ error: `Parameter ${key} has invalid format`
99
+ });
100
+ }
101
+
102
+ normalized[key] = value;
103
+ } else if (config.required) {
104
+ return res.status(400).json({
105
+ error: `Parameter ${key} is required`
106
+ });
107
+ } else {
108
+ normalized[key] = config.default || '';
109
+ }
110
+ } else if (value !== undefined) {
111
+ return res.status(400).json({
112
+ error: `Unknown parameter: ${key}`
113
+ });
114
+ }
115
+ }
116
+
117
+ for (const [key, config] of Object.entries(allowedParams)) {
118
+ if (normalized[key] === undefined && !config.required) {
119
+ normalized[key] = config.default || '';
120
+ }
121
+ }
122
+
123
+ // Clear and repopulate in-place (Express 5 makes req.query a read-only getter)
124
+ for (const key of Object.keys(req.query)) delete req.query[key];
125
+ Object.assign(req.query, normalized);
126
+ next();
127
+ } catch (error) {
128
+ shlLog.error('Parameter validation error:', error);
129
+ res.status(500).json({ error: 'Parameter validation failed' });
130
+ }
131
+ };
132
+ }
133
+
134
+ // Body validation middleware
135
+ validateJsonBody(requiredFields = [], optionalFields = []) {
136
+ return (req, res, next) => {
137
+ try {
138
+ if (!req.body || typeof req.body !== 'object') {
139
+ return res.status(400).json({error: 'Request body must be JSON object'});
140
+ }
141
+
142
+ // Check for required fields
143
+ for (const field of requiredFields) {
144
+ if (req.body[field] === undefined || req.body[field] === null) {
145
+ return res.status(400).json({error: `Missing required field: ${field}`});
146
+ }
147
+ }
148
+
149
+ // Validate known fields
150
+ const allowedFields = [...requiredFields, ...optionalFields];
151
+ for (const [key, value] of Object.entries(req.body)) {
152
+ if (!allowedFields.includes(key)) {
153
+ return res.status(400).json({error: `Unknown field: ${key}`});
154
+ }
155
+
156
+ // Basic type validation
157
+ if (key === 'vhl' && typeof value !== 'boolean') {
158
+ return res.status(400).json({error: 'vhl must be boolean'});
159
+ }
160
+
161
+ if ((key === 'password' || key === 'pword') && typeof value !== 'string') {
162
+ return res.status(400).json({error: `${key} must be string`});
163
+ }
164
+
165
+ if (key === 'days') {
166
+ const daysNum = typeof value === 'string' ? parseInt(value, 10) : value;
167
+ if (isNaN(daysNum) || daysNum < 1 || daysNum > 365) {
168
+ return res.status(400).json({error: 'days must be between 1 and 365'});
169
+ }
170
+ req.body[key] = daysNum; // Normalize to number
171
+ }
172
+
173
+ if (key === 'files' && !Array.isArray(value)) {
174
+ return res.status(400).json({error: 'files must be array'});
175
+ }
176
+
177
+ // String length limits
178
+ if (typeof value === 'string') {
179
+ const maxLengths = {
180
+ password: 100,
181
+ pword: 100,
182
+ uuid: 50,
183
+ url: 2000,
184
+ packageId: 100,
185
+ version: 50,
186
+ recipient: 100,
187
+ embeddedLengthMax: 10
188
+ };
189
+
190
+ if (maxLengths[key] && value.length > maxLengths[key]) {
191
+ return res.status(400).json({
192
+ error: `${key} too long (max ${maxLengths[key]})`
193
+ });
194
+ }
195
+ }
196
+ }
197
+
198
+ next();
199
+ } catch (error) {
200
+ shlLog.error('Body validation error:', error);
201
+ res.status(500).json({error: 'Request validation failed'});
202
+ }
203
+ };
204
+ }
205
+
206
+ // Secure comparison function
207
+ secureCompare(a, b) {
208
+ if (typeof a !== 'string' || typeof b !== 'string') {
209
+ return false;
210
+ }
211
+
212
+ if (a.length !== b.length) {
213
+ // Still do a comparison to prevent timing attacks
214
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(a));
215
+ return false;
216
+ }
217
+
218
+ try {
219
+ return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
220
+ } catch (error) {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ // Enhanced HTML escaping
226
+ escapeHtml(str) {
227
+ if (!str || typeof str !== 'string') return '';
228
+
229
+ const escapeMap = {
230
+ '&': '&amp;',
231
+ '<': '&lt;',
232
+ '>': '&gt;',
233
+ '"': '&quot;',
234
+ "'": '&#x27;',
235
+ '/': '&#x2F;',
236
+ '`': '&#x60;',
237
+ '=': '&#x3D;'
238
+ };
239
+
240
+ return str.replace(/[&<>"'`=/]/g, (match) => escapeMap[match]);
241
+ }
242
+
243
+ // URL validation
244
+ validateExternalUrl(url) {
245
+ try {
246
+ const parsed = new URL(url);
247
+
248
+ if (!['http:', 'https:', 'shlink:'].includes(parsed.protocol)) {
249
+ throw new Error(`Protocol ${parsed.protocol} not allowed`);
250
+ }
251
+
252
+ // Block private IP ranges
253
+ const hostname = parsed.hostname;
254
+ if (hostname === 'localhost' ||
255
+ hostname === '127.0.0.1' ||
256
+ hostname.startsWith('10.') ||
257
+ hostname.startsWith('192.168.') ||
258
+ /^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname)) {
259
+ throw new Error('Private IP addresses not allowed');
260
+ }
261
+
262
+ return parsed;
263
+ } catch (error) {
264
+ throw new Error(`Invalid URL: ${error.message}`);
265
+ }
266
+ }
267
+
268
+ async initialize(config) {
269
+ this.config = config;
270
+
271
+ // Initialize database
272
+ await this.initializeDatabase();
273
+
274
+ // Initialize FHIR Validator if enabled
275
+ if (config.validator.enabled) {
276
+ await this.initializeFhirValidator();
277
+ }
278
+
279
+ // Start cleanup cron job
280
+ this.startCleanupJob();
281
+
282
+ shlLog.info('SHL module initialized successfully');
283
+ }
284
+
285
+ async initializeDatabase() {
286
+ return new Promise((resolve, reject) => {
287
+
288
+ const dbPath = folders.filePath('shl', this.config.database); // <-- CHANGE
289
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
290
+
291
+ this.db = new sqlite3.Database(dbPath, (err) => {
292
+ if (err) {
293
+ shlLog.error('Error opening SHL SQLite database at "'+dbPath+'":', err.message);
294
+ reject(err);
295
+ } else {
296
+ shlLog.info('Connected to SHL SQLite database at "'+dbPath+'"');
297
+
298
+ // Check if tables already exist before creating them
299
+ this.db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='SHL'", (err, row) => {
300
+ if (err) {
301
+ reject(err);
302
+ } else if (row) {
303
+ // Tables already exist, no need to create them
304
+ resolve();
305
+ } else {
306
+ // Tables don't exist, create them
307
+ this.createTables().then(resolve).catch(reject);
308
+ }
309
+ });
310
+ }
311
+ });
312
+ });
313
+ }
314
+
315
+ async createTables() {
316
+ return new Promise((resolve, reject) => {
317
+ const createSHLTable = `
318
+ CREATE TABLE IF NOT EXISTS SHL (
319
+ uuid TEXT PRIMARY KEY,
320
+ vhl BOOLEAN NOT NULL,
321
+ expires_at DATETIME NOT NULL,
322
+ password TEXT NOT NULL,
323
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
324
+ )
325
+ `;
326
+
327
+ const createSHLFilesTable = `
328
+ CREATE TABLE IF NOT EXISTS SHLFiles (
329
+ id TEXT PRIMARY KEY,
330
+ shl_uuid TEXT NOT NULL,
331
+ cnt TEXT NOT NULL,
332
+ type TEXT NOT NULL,
333
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
334
+ FOREIGN KEY (shl_uuid) REFERENCES SHL (uuid) ON DELETE CASCADE
335
+ )
336
+ `;
337
+
338
+ const createSHLViewsTable = `
339
+ CREATE TABLE IF NOT EXISTS SHLViews (
340
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
341
+ shl_uuid TEXT NOT NULL,
342
+ recipient TEXT,
343
+ ip_address TEXT,
344
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
345
+ FOREIGN KEY (shl_uuid) REFERENCES SHL (uuid) ON DELETE CASCADE
346
+ )
347
+ `;
348
+
349
+ let tablesCreated = 0;
350
+ const totalTables = 3;
351
+
352
+ const checkComplete = () => {
353
+ tablesCreated++;
354
+ if (tablesCreated === totalTables) {
355
+ shlLog.info('SHL database initialized');
356
+ resolve();
357
+ }
358
+ };
359
+
360
+ this.db.run(createSHLTable, (err) => {
361
+ if (err) {
362
+ shlLog.error('Error creating SHL table:', err.message);
363
+ reject(err);
364
+ } else {
365
+ checkComplete();
366
+ }
367
+ });
368
+
369
+ this.db.run(createSHLFilesTable, (err) => {
370
+ if (err) {
371
+ shlLog.error('Error creating SHLFiles table:', err.message);
372
+ reject(err);
373
+ } else {
374
+ checkComplete();
375
+ }
376
+ });
377
+
378
+ this.db.run(createSHLViewsTable, (err) => {
379
+ if (err) {
380
+ shlLog.error('Error creating SHLViews table:', err.message);
381
+ reject(err);
382
+ } else {
383
+ checkComplete();
384
+ }
385
+ });
386
+ });
387
+ }
388
+
389
+ async initializeFhirValidator() {
390
+ try {
391
+ shlLog.info('Initializing FHIR Validator...');
392
+
393
+ const validatorConfig = {
394
+ version: this.config.validator.version,
395
+ txServer: this.config.validator.txServer,
396
+ txLog: this.config.validator.txLog,
397
+ port: this.config.validator.port,
398
+ igs: this.config.validator.packages,
399
+ timeout: this.config.validator.timeout
400
+ };
401
+
402
+ shlLog.info('Starting FHIR Validator with config:', validatorConfig);
403
+
404
+ const validatorJarPath = folders.filePath('bin', 'validator_cli.jar');
405
+ this.fhirValidator = new FhirValidator(validatorJarPath, shlLog);
406
+ await this.fhirValidator.start(validatorConfig);
407
+
408
+ shlLog.info('FHIR Validator started successfully');
409
+ } catch (error) {
410
+ shlLog.error('Failed to start FHIR Validator:', error);
411
+ throw error;
412
+ }
413
+ }
414
+
415
+ loadCertificates() {
416
+ try {
417
+ const certPath = path.resolve(__dirname, this.config.certificates.certFile);
418
+ const keyPath = path.resolve(__dirname, this.config.certificates.keyFile);
419
+
420
+ // Validate paths to prevent directory traversal
421
+ if (!certPath.startsWith(path.resolve(__dirname)) ||
422
+ !keyPath.startsWith(path.resolve(__dirname))) {
423
+ throw new Error('Certificate paths outside allowed directory');
424
+ }
425
+
426
+ const certPem = fs.readFileSync(certPath, 'utf8');
427
+ const keyPem = fs.readFileSync(keyPath, 'utf8');
428
+
429
+ return { certPem, keyPem };
430
+ } catch (error) {
431
+ throw new Error(`Failed to load certificates: ${error.message}`);
432
+ }
433
+ }
434
+
435
+ startCleanupJob() {
436
+ if (this.config.cleanup && this.config.cleanup.schedule) {
437
+ this.cleanupJob = cron.schedule(this.config.cleanup.schedule, () => {
438
+ shlLog.info('Running scheduled cleanup of expired SHL entries...');
439
+ this.cleanupExpiredEntries();
440
+ });
441
+ shlLog.info(`SHL cleanup job scheduled: ${this.config.cleanup.schedule}`);
442
+ }
443
+ }
444
+
445
+ stopCleanupJob() {
446
+ if (this.cleanupJob) {
447
+ this.cleanupJob.stop();
448
+ this.cleanupJob = null;
449
+ }
450
+ }
451
+
452
+ cleanupExpiredEntries() {
453
+ const deleteSql = 'DELETE FROM SHL WHERE expires_at < datetime("now")';
454
+
455
+ this.db.run(deleteSql, function(err) {
456
+ if (err) {
457
+ shlLog.error('SHL cleanup error:', err.message);
458
+ } else if (this.changes > 0) {
459
+ shlLog.info(`Cleaned up ${this.changes} expired SHL entries`);
460
+ }
461
+ });
462
+ }
463
+
464
+ generateUUID() {
465
+ return crypto.randomUUID();
466
+ }
467
+
468
+ // Helper function to convert PEM to JWK for COSE signing
469
+ pemToJwk(pemCert, pemKey) {
470
+ try {
471
+ const keyObject = crypto.createPrivateKey(pemKey);
472
+ const keyType = keyObject.asymmetricKeyType;
473
+
474
+ if (keyType !== 'ec') {
475
+ throw new Error('Only EC (Elliptic Curve) keys are supported for COSE signing');
476
+ }
477
+
478
+ const jwk = keyObject.export({ format: 'jwk' });
479
+ return jwk;
480
+ } catch (error) {
481
+ throw new Error(`Failed to convert PEM to JWK: ${error.message}`);
482
+ }
483
+ }
484
+
485
+ // Helper function to convert DER signature to raw r||s format
486
+ derToRaw(derSignature) {
487
+ let offset = 2; // Skip SEQUENCE tag and length
488
+
489
+ // First INTEGER (r)
490
+ offset++; // Skip INTEGER tag
491
+ const rLen = derSignature[offset++];
492
+ const r = Buffer.alloc(32);
493
+
494
+ const rStart = Math.max(0, rLen - 32);
495
+ const rCopyLen = Math.min(rLen, 32);
496
+ derSignature.copy(r, 32 - rCopyLen, offset + rStart, offset + rLen);
497
+ offset += rLen;
498
+
499
+ // Second INTEGER (s)
500
+ offset++; // Skip INTEGER tag
501
+ const sLen = derSignature[offset++];
502
+ const s = Buffer.alloc(32);
503
+
504
+ const sStart = Math.max(0, sLen - 32);
505
+ const sCopyLen = Math.min(sLen, 32);
506
+ derSignature.copy(s, 32 - sCopyLen, offset + sStart, offset + sLen);
507
+
508
+ return Buffer.concat([r, s]);
509
+ }
510
+
511
+ async createCOSESign1(payload, privateKeyJWK, kid) {
512
+ try {
513
+ const protectedHeaders = new Map();
514
+ protectedHeaders.set(1, -7); // alg: ES256
515
+ protectedHeaders.set(4, kid); // kid
516
+
517
+ const protectedEncoded = CBOR.encode(protectedHeaders);
518
+
519
+ const sigStructure = [
520
+ "Signature1",
521
+ protectedEncoded,
522
+ Buffer.alloc(0),
523
+ payload
524
+ ];
525
+
526
+ const sigStructureEncoded = CBOR.encode(sigStructure);
527
+
528
+ const privateKey = crypto.createPrivateKey({
529
+ key: {
530
+ kty: privateKeyJWK.kty,
531
+ crv: privateKeyJWK.crv,
532
+ x: privateKeyJWK.x,
533
+ y: privateKeyJWK.y,
534
+ d: privateKeyJWK.d
535
+ },
536
+ format: 'jwk'
537
+ });
538
+
539
+ const signer = crypto.createSign('SHA256');
540
+ signer.update(sigStructureEncoded);
541
+ const signatureDER = signer.sign(privateKey);
542
+
543
+ const rawSignature = this.derToRaw(signatureDER);
544
+
545
+ const coseSign1Array = [
546
+ protectedEncoded,
547
+ new Map(),
548
+ payload,
549
+ rawSignature
550
+ ];
551
+
552
+ const taggedMessage = new CBOR.Tagged(18, coseSign1Array);
553
+ const encoded = CBOR.encode(taggedMessage);
554
+
555
+ return encoded;
556
+
557
+ } catch (error) {
558
+ shlLog.error('COSE Sign1 creation error:', error);
559
+ throw error;
560
+ }
561
+ }
562
+
563
+ setupRoutes() {
564
+ // Validation parameter configs
565
+ const validationParams = {
566
+ profiles: { maxLength: 500, pattern: /^[a-zA-Z0-9.:/_,-]*$/ },
567
+ resourceIdRule: { maxLength: 50, pattern: /^[a-zA-Z0-9_-]*$/ },
568
+ anyExtensionsAllowed: { maxLength: 10, pattern: /^(true|false)?$/ },
569
+ bpWarnings: { maxLength: 50, pattern: /^[a-zA-Z0-9_-]*$/ },
570
+ displayOption: { maxLength: 50, pattern: /^[a-zA-Z0-9_-]*$/ }
571
+ };
572
+
573
+ // FHIR Validation endpoint
574
+ this.router.post('/validate', this.validateQueryParams(validationParams), async (req, res) => {
575
+ const start = Date.now();
576
+ try {
577
+ if (!this.fhirValidator || !this.fhirValidator.isRunning()) {
578
+ return res.status(503).json({
579
+ resourceType: 'OperationOutcome',
580
+ issue: [{
581
+ severity: 'error',
582
+ code: 'exception',
583
+ diagnostics: 'FHIR Validator service is not available'
584
+ }]
585
+ });
586
+ }
587
+
588
+ try {
589
+ const options = {};
590
+
591
+ if (req.query.profiles) {
592
+ options.profiles = req.query.profiles.split(',');
593
+ }
594
+ if (req.query.resourceIdRule) {
595
+ options.resourceIdRule = req.query.resourceIdRule;
596
+ }
597
+ if (req.query.anyExtensionsAllowed !== undefined) {
598
+ options.anyExtensionsAllowed = req.query.anyExtensionsAllowed === 'true';
599
+ }
600
+ if (req.query.bpWarnings) {
601
+ options.bpWarnings = req.query.bpWarnings;
602
+ }
603
+ if (req.query.displayOption) {
604
+ options.displayOption = req.query.displayOption;
605
+ }
606
+ shlLog.info("validate! (4)");
607
+
608
+ let resource;
609
+ if (Buffer.isBuffer(req.body)) {
610
+ resource = req.body;
611
+ } else if (typeof req.body === 'string') {
612
+ resource = req.body;
613
+ } else {
614
+ resource = JSON.stringify(req.body);
615
+ }
616
+ shlLog.info("validate! (5)");
617
+
618
+ const operationOutcome = await this.fhirValidator.validate(resource, options);
619
+ shlLog.info("validate! (6)");
620
+
621
+ res.json(operationOutcome);
622
+ shlLog.info("validate! (7)");
623
+
624
+ } catch (error) {
625
+ shlLog.error('Validation error:', error);
626
+ res.status(500).json({
627
+ resourceType: 'OperationOutcome',
628
+ issue: [{
629
+ severity: 'error',
630
+ code: 'exception',
631
+ diagnostics: `Validation failed: ${error.message}`
632
+ }]
633
+ });
634
+ }
635
+ } finally {
636
+ this.stats.countRequest('validate', Date.now() - start);
637
+ }
638
+ });
639
+
640
+ // Validator status endpoint
641
+ this.router.get('/validate/status', (req, res) => {
642
+ const start = Date.now();
643
+ try {
644
+ const status = {
645
+ validatorRunning: this.fhirValidator ? this.fhirValidator.isRunning() : false,
646
+ validatorInitialized: this.fhirValidator !== null
647
+ };
648
+
649
+ res.json(status);
650
+ } finally {
651
+ this.stats.countRequest('status', Date.now() - start);
652
+ }
653
+ });
654
+
655
+ // Load additional IG endpoint
656
+ this.router.post('/validate/loadig', this.validateJsonBody(['packageId', 'version']), async (req, res) => {
657
+ const start = Date.now();
658
+ try {
659
+ if (!this.fhirValidator || !this.fhirValidator.isRunning()) {
660
+ return res.status(503).json({
661
+ resourceType: 'OperationOutcome',
662
+ issue: [{
663
+ severity: 'error',
664
+ code: 'exception',
665
+ diagnostics: 'FHIR Validator service is not available'
666
+ }]
667
+ });
668
+ }
669
+
670
+ const {packageId, version} = req.body;
671
+
672
+ if (!packageId || !version) {
673
+ return res.status(400).json({
674
+ resourceType: 'OperationOutcome',
675
+ issue: [{
676
+ severity: 'error',
677
+ code: 'required',
678
+ diagnostics: 'packageId and version are required'
679
+ }]
680
+ });
681
+ }
682
+
683
+ try {
684
+ const result = await this.fhirValidator.loadIG(packageId, version);
685
+ res.json(result);
686
+ } catch (error) {
687
+ shlLog.error('Load IG error:', error);
688
+ res.status(500).json({
689
+ resourceType: 'OperationOutcome',
690
+ issue: [{
691
+ severity: 'error',
692
+ code: 'exception',
693
+ diagnostics: `Failed to load IG: ${error.message}`
694
+ }]
695
+ });
696
+ }
697
+ } finally {
698
+ this.stats.countRequest('loadIG', Date.now() - start);
699
+ }
700
+ });
701
+
702
+ // SHL create endpoint
703
+ this.router.post('/create', this.validateJsonBody(['vhl', 'password', 'days']), (req, res) => {
704
+ const start = Date.now();
705
+ try {
706
+ const {vhl, password, days} = req.body;
707
+
708
+ if (typeof vhl !== 'boolean' || !password) {
709
+ return res.status(400).json({
710
+ error: 'Invalid request. Required: vhl (boolean), password (string), days (number or string)'
711
+ });
712
+ }
713
+
714
+ let daysNumber;
715
+ if (typeof days === 'string') {
716
+ daysNumber = parseInt(days, 10);
717
+ if (isNaN(daysNumber)) {
718
+ return res.status(400).json({
719
+ error: 'days must be a valid number or numeric string'
720
+ });
721
+ }
722
+ } else if (typeof days === 'number') {
723
+ daysNumber = days;
724
+ } else {
725
+ return res.status(400).json({
726
+ error: 'days is required and must be a number or numeric string'
727
+ });
728
+ }
729
+
730
+ if (password !== this.config.password) {
731
+ return res.status(401).json({error: 'Unauthorized'});
732
+ }
733
+
734
+ const uuid = this.generateUUID();
735
+ const newPassword = this.generateUUID();
736
+
737
+ const expiryDate = new Date();
738
+ expiryDate.setDate(expiryDate.getDate() + daysNumber);
739
+ const expiryDateString = expiryDate.toISOString();
740
+
741
+ const insertSql = 'INSERT INTO SHL (uuid, vhl, expires_at, password) VALUES (?, ?, ?, ?)';
742
+
743
+ this.db.run(insertSql, [uuid, vhl, expiryDateString, newPassword], function (err) {
744
+ if (err) {
745
+ return res.status(500).json({error: 'Failed to create SHL entry: ' + err.message});
746
+ }
747
+
748
+ const host = req.get('host') || 'localhost:3000';
749
+
750
+ res.status(201).json({
751
+ uuid: uuid,
752
+ pword: newPassword,
753
+ link: `https://${host}/shl/access/${uuid}`
754
+ });
755
+ });
756
+ } finally {
757
+ this.stats.countRequest('create', Date.now() - start);
758
+ }
759
+ });
760
+
761
+ // SHL upload endpoint
762
+ this.router.post('/upload', this.validateJsonBody(['uuid', 'pword', 'files']), (req, res) => {
763
+ const start = Date.now();
764
+ try {
765
+ const {uuid, pword, files} = req.body;
766
+
767
+ if (!uuid || !pword || !Array.isArray(files)) {
768
+ return res.status(400).json({
769
+ error: 'Invalid request. Required: uuid (string), pword (string), files (array)'
770
+ });
771
+ }
772
+
773
+ for (const f of files) {
774
+ if (!f.cnt || !f.type) {
775
+ return res.status(400).json({
776
+ error: 'Invalid file format. Each file must have cnt (base64) and type (mime type)'
777
+ });
778
+ }
779
+ }
780
+
781
+ const checkSHLSql = 'SELECT vhl, password FROM SHL WHERE uuid = ?';
782
+
783
+ this.db.get(checkSHLSql, [uuid], (err, row) => {
784
+ if (err) {
785
+ return res.status(500).json({error: 'Database error'});
786
+ }
787
+
788
+ if (!row) {
789
+ return res.status(404).json({error: 'SHL entry not found'});
790
+ }
791
+
792
+ if (row.password !== pword) {
793
+ return res.status(401).json({error: 'Unauthorized'});
794
+ }
795
+
796
+ const deleteExistingFilesSql = 'DELETE FROM SHLFiles WHERE shl_uuid = ?';
797
+
798
+ this.db.run(deleteExistingFilesSql, [uuid], (err) => {
799
+ if (err) {
800
+ return res.status(500).json({error: 'Failed to clear existing files'});
801
+ }
802
+
803
+ const insertPromises = files.map((f) => {
804
+ return new Promise((resolve, reject) => {
805
+ const fileId = this.generateUUID();
806
+ const insertFileSql = 'INSERT INTO SHLFiles (id, shl_uuid, cnt, type) VALUES (?, ?, ?, ?)';
807
+
808
+ this.db.run(insertFileSql, [fileId, uuid, f.cnt, f.type], function (err) {
809
+ if (err) {
810
+ reject(err);
811
+ } else {
812
+ resolve();
813
+ }
814
+ });
815
+ });
816
+ });
817
+
818
+ Promise.all(insertPromises)
819
+ .then(() => {
820
+ res.json({msg: 'ok'});
821
+ })
822
+ .catch((error) => {
823
+ shlLog.error('File upload error:', error);
824
+ res.status(500).json({error: 'Failed to upload files'});
825
+ });
826
+ });
827
+ });
828
+ } finally {
829
+ this.stats.countRequest('upload', Date.now() - start);
830
+ }
831
+ });
832
+
833
+ // Helper function for the shared access logic
834
+ const handleSHLAccess = (req, res) => {
835
+ const start = Date.now();
836
+ try {
837
+ const {uuid} = req.params;
838
+
839
+ let recipient, embeddedLengthMax;
840
+
841
+ if (req.method === 'GET') {
842
+ recipient = 'anonymous';
843
+ embeddedLengthMax = undefined;
844
+ } else {
845
+ ({recipient, embeddedLengthMax} = req.body);
846
+
847
+ if (!recipient) {
848
+ return res.status(400).json({
849
+ error: 'recipient is required in request body'
850
+ });
851
+ }
852
+ }
853
+
854
+ const clientIP = req.ip || req.connection.remoteAddress || req.socket.remoteAddress ||
855
+ (req.connection.socket ? req.connection.socket.remoteAddress : null) ||
856
+ req.headers['x-forwarded-for'] || 'unknown';
857
+
858
+ const checkSHLSql = 'SELECT uuid, vhl FROM SHL WHERE uuid = ? AND expires_at > datetime("now")';
859
+
860
+ this.db.get(checkSHLSql, [uuid], (err, shlRow) => {
861
+ if (err) {
862
+ return res.status(500).json({error: 'Database error'});
863
+ }
864
+
865
+ if (!shlRow) {
866
+ return res.status(404).json({error: 'SHL entry not found or expired'});
867
+ }
868
+
869
+ const logAccessSql = 'INSERT INTO SHLViews (shl_uuid, recipient, ip_address) VALUES (?, ?, ?)';
870
+
871
+ this.db.run(logAccessSql, [uuid, recipient, clientIP], function (logErr) {
872
+ if (logErr) {
873
+ shlLog.error('Failed to log SHL access:', logErr.message);
874
+ }
875
+
876
+ const getFilesSql = 'SELECT id, cnt, type FROM SHLFiles WHERE shl_uuid = ?';
877
+
878
+ this.db.all(getFilesSql, [uuid], (err, fileRows) => {
879
+ if (err) {
880
+ return res.status(500).json({error: 'Failed to retrieve files'});
881
+ }
882
+
883
+ const host = req.get('host') || 'localhost:3000';
884
+ const protocol = req.secure ? 'https' : 'http';
885
+ const maxLength = embeddedLengthMax ? parseInt(embeddedLengthMax) : undefined;
886
+
887
+ const files = fileRows.map(file => {
888
+ const fileResponse = {
889
+ contentType: file.type,
890
+ location: `${protocol}://${host}/shl/file/${file.id}`
891
+ };
892
+
893
+ if (maxLength === undefined || file.cnt.length <= maxLength) {
894
+ fileResponse.embedded = file.cnt;
895
+ }
896
+
897
+ return fileResponse;
898
+ });
899
+
900
+ const standardResponse = {files};
901
+
902
+ if (shlRow.vhl && vhlProcessor) {
903
+ try {
904
+ const vhlResponse = vhlProcessor.processVHL(host, uuid, standardResponse);
905
+ res.json(vhlResponse);
906
+ } catch (vhlErr) {
907
+ shlLog.error('VHL processing error:', vhlErr.message);
908
+ res.json(standardResponse);
909
+ }
910
+ } else {
911
+ res.json(standardResponse);
912
+ }
913
+ });
914
+ });
915
+ });
916
+ } finally {
917
+ this.stats.countRequest('access', Date.now() - start);
918
+ }
919
+ };
920
+
921
+ // SHL access endpoint - supports both GET and POST
922
+ this.router.get('/access/:uuid', handleSHLAccess);
923
+ this.router.post('/access/:uuid', this.validateJsonBody(['recipient'], ['embeddedLengthMax']), handleSHLAccess);
924
+
925
+ // SHL file endpoint - serves individual files
926
+ this.router.get('/file/:fileId', (req, res) => {
927
+ const start = Date.now();
928
+ try {
929
+ const {fileId} = req.params;
930
+
931
+ // Validate fileId format
932
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(fileId)) {
933
+ return res.status(400).json({error: 'Invalid file ID format'});
934
+ }
935
+
936
+ const clientIP = req.ip || req.connection.remoteAddress || req.socket.remoteAddress ||
937
+ (req.connection.socket ? req.connection.socket.remoteAddress : null) ||
938
+ req.headers['x-forwarded-for'] || 'unknown';
939
+
940
+ const getFileSql = 'SELECT id, shl_uuid, cnt, type FROM SHLFiles WHERE id = ?';
941
+
942
+ this.db.get(getFileSql, [fileId], (err, fileRow) => {
943
+ if (err) {
944
+ return res.status(500).json({error: 'Database error'});
945
+ }
946
+
947
+ if (!fileRow) {
948
+ return res.status(404).json({error: 'File not found'});
949
+ }
950
+
951
+ const logMasterAccessSql = 'INSERT INTO SHLViews (shl_uuid, recipient, ip_address) VALUES (?, ?, ?)';
952
+ const logFileAccessSql = 'INSERT INTO SHLViews (shl_uuid, recipient, ip_address) VALUES (?, ?, ?)';
953
+
954
+ this.db.run(logMasterAccessSql, [fileRow.shl_uuid, null, clientIP], function (logErr) {
955
+ if (logErr) {
956
+ shlLog.error('Failed to log master SHL file access:', logErr.message);
957
+ }
958
+ });
959
+
960
+ this.db.run(logFileAccessSql, [fileRow.id, null, clientIP], function (logErr) {
961
+ if (logErr) {
962
+ shlLog.error('Failed to log file-specific access:', logErr.message);
963
+ }
964
+ });
965
+
966
+ try {
967
+ const fileBuffer = Buffer.from(fileRow.cnt, 'base64');
968
+ res.set('Content-Type', 'application/jose');
969
+ res.send(fileBuffer);
970
+ } catch (decodeErr) {
971
+ res.status(500).json({error: 'Failed to decode file content'});
972
+ }
973
+ });
974
+ } finally {
975
+ this.stats.countRequest('file', Date.now() - start);
976
+ }
977
+ });
978
+
979
+ // SHL sign endpoint
980
+ this.router.post('/sign', this.validateJsonBody(['url']), async (req, res) => {
981
+ const start = Date.now();
982
+ try {
983
+ const {url} = req.body;
984
+
985
+ if (!url || typeof url !== 'string') {
986
+ return res.status(400).json({
987
+ error: 'url is required and must be a string'
988
+ });
989
+ }
990
+
991
+ try {
992
+ // Validate URL
993
+ this.validateExternalUrl(url);
994
+
995
+ const {certPem, keyPem} = this.loadCertificates();
996
+ const {kid} = this.config.certificates;
997
+ const {issuer} = this.config.vhl;
998
+
999
+ if (!certPem || !keyPem) {
1000
+ return res.status(500).json({error: 'Certificate or private key files not found'});
1001
+ }
1002
+
1003
+ if (!kid) {
1004
+ return res.status(500).json({error: 'KID not configured'});
1005
+ }
1006
+
1007
+ try {
1008
+ const jwk = this.pemToJwk(certPem, keyPem);
1009
+
1010
+ const payload = {
1011
+ "1": issuer,
1012
+ "-260": {
1013
+ "5": [url]
1014
+ }
1015
+ };
1016
+
1017
+ const cborEncoded = CBOR.encode(payload);
1018
+ const coseSigned = await this.createCOSESign1(cborEncoded, jwk, kid);
1019
+ const deflated = pako.deflate(coseSigned);
1020
+ const base45Encoded = base45.encode(deflated);
1021
+
1022
+ const publicJwk = {
1023
+ kty: jwk.kty,
1024
+ crv: jwk.crv,
1025
+ x: jwk.x,
1026
+ y: jwk.y
1027
+ };
1028
+
1029
+ res.json({
1030
+ signature: base45Encoded,
1031
+ steps: {
1032
+ input: {
1033
+ url: url,
1034
+ issuer: issuer,
1035
+ kid: kid
1036
+ },
1037
+ step1_payload: payload,
1038
+ step1_payload_json: JSON.stringify(payload),
1039
+ step2_cbor_encoded: Array.from(cborEncoded),
1040
+ step2_cbor_encoded_hex: cborEncoded.toString('hex'),
1041
+ step2_cbor_encoded_base64: cborEncoded.toString('base64'),
1042
+ step3_cose_signed: Array.from(coseSigned),
1043
+ step3_cose_signed_hex: coseSigned.toString('hex'),
1044
+ step3_cose_signed_base64: coseSigned.toString('base64'),
1045
+ step4_deflated: Array.from(deflated),
1046
+ step4_deflated_hex: Buffer.from(deflated).toString('hex'),
1047
+ step4_deflated_base64: Buffer.from(deflated).toString('base64'),
1048
+ step5_base45_encoded: base45Encoded,
1049
+ crypto_info: {
1050
+ public_key_jwk: publicJwk,
1051
+ certificate_pem: certPem,
1052
+ algorithm: "ES256",
1053
+ curve: "P-256"
1054
+ },
1055
+ sizes: {
1056
+ original_url_bytes: Buffer.byteLength(url, 'utf8'),
1057
+ payload_json_bytes: Buffer.byteLength(JSON.stringify(payload), 'utf8'),
1058
+ cbor_encoded_bytes: cborEncoded.length,
1059
+ cose_signed_bytes: coseSigned.length,
1060
+ deflated_bytes: deflated.length,
1061
+ base45_encoded_bytes: Buffer.byteLength(base45Encoded, 'utf8')
1062
+ }
1063
+ }
1064
+ });
1065
+
1066
+ } catch (error) {
1067
+ shlLog.error('SHL sign processing error:', error);
1068
+ res.status(500).json({
1069
+ error: 'Failed to sign URL: ' + error.message
1070
+ });
1071
+ }
1072
+ } catch (error) {
1073
+ shlLog.error('SHL sign error:', error);
1074
+ res.status(500).json({
1075
+ error: 'Failed to sign URL: ' + error.message
1076
+ });
1077
+ }
1078
+ } finally {
1079
+ this.stats.countRequest('sign', Date.now() - start);
1080
+ }
1081
+ });
1082
+ }
1083
+
1084
+ async shutdown() {
1085
+ shlLog.info('Shutting down SHL module...');
1086
+
1087
+ this.stopCleanupJob();
1088
+
1089
+ // Stop FHIR validator
1090
+ if (this.fhirValidator) {
1091
+ try {
1092
+ shlLog.info('Stopping FHIR validator...');
1093
+ await this.fhirValidator.stop();
1094
+ shlLog.info('FHIR validator stopped');
1095
+ } catch (error) {
1096
+ shlLog.error('Error stopping FHIR validator:', error);
1097
+ }
1098
+ }
1099
+
1100
+ if (this.db) {
1101
+ return new Promise((resolve) => {
1102
+ this.db.close((err) => {
1103
+ if (err) {
1104
+ shlLog.error('Error closing SHL database:', err.message);
1105
+ } else {
1106
+ shlLog.info('SHL database connection closed');
1107
+ }
1108
+ resolve();
1109
+ });
1110
+ });
1111
+ }
1112
+ }
1113
+
1114
+ getStatus() {
1115
+ return {
1116
+ enabled: true,
1117
+ database: this.db ? 'Connected' : 'Disconnected',
1118
+ cleanupJob: this.cleanupJob ? 'Running' : 'Stopped',
1119
+ validator: this.fhirValidator ? (this.fhirValidator.isRunning() ? 'Running' : 'Stopped') : 'Not initialized'
1120
+ };
1121
+ }
1122
+
1123
+ }
1124
+
1125
+ module.exports = SHLModule;