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.
- package/CHANGELOG.md +42 -0
- package/FHIRsmith.png +0 -0
- package/README.md +277 -0
- package/config-template.json +144 -0
- package/library/folder-setup.js +58 -0
- package/library/html-server.js +166 -0
- package/library/html.js +835 -0
- package/library/i18nsupport.js +259 -0
- package/library/languages.js +779 -0
- package/library/logger-telnet.js +205 -0
- package/library/logger.js +279 -0
- package/library/package-manager.js +876 -0
- package/library/utilities.js +196 -0
- package/library/version-utilities.js +1056 -0
- package/npmprojector/config-example.json +13 -0
- package/npmprojector/indexer.js +394 -0
- package/npmprojector/npmprojector.js +395 -0
- package/npmprojector/readme.md +174 -0
- package/npmprojector/watcher.js +335 -0
- package/package.json +119 -0
- package/packages/package-crawler.js +846 -0
- package/packages/packages-template.html +126 -0
- package/packages/packages.js +2838 -0
- package/passwords.ini +2 -0
- package/publisher/publisher-template.html +208 -0
- package/publisher/publisher.js +2167 -0
- package/publisher/task-draft.js +458 -0
- package/registry/api.js +735 -0
- package/registry/crawler.js +637 -0
- package/registry/model.js +513 -0
- package/registry/readme.md +243 -0
- package/registry/registry-data.json +121015 -0
- package/registry/registry-template.html +126 -0
- package/registry/registry.js +1395 -0
- package/registry/test-runner.js +237 -0
- package/root-template.html +124 -0
- package/server.js +524 -0
- package/shl/private-key.pem +5 -0
- package/shl/public-key.pem +18 -0
- package/shl/shl.js +1125 -0
- package/shl/vhl.js +69 -0
- package/static/FHIRsmith128.png +0 -0
- package/static/FHIRsmith16.png +0 -0
- package/static/FHIRsmith32.png +0 -0
- package/static/FHIRsmith64.png +0 -0
- package/static/assets/css/bootstrap-fhir.css +5302 -0
- package/static/assets/css/bootstrap-glyphicons.css +2 -0
- package/static/assets/css/bootstrap.css +4097 -0
- package/static/assets/css/jquery-ui.css +523 -0
- package/static/assets/css/jquery-ui.structure.css +863 -0
- package/static/assets/css/jquery-ui.structure.min.css +5 -0
- package/static/assets/css/jquery-ui.theme.css +439 -0
- package/static/assets/css/jquery-ui.theme.min.css +5 -0
- package/static/assets/css/jquery.ui.all.css +7 -0
- package/static/assets/css/modules.css +18 -0
- package/static/assets/css/project.css +367 -0
- package/static/assets/css/pygments-manni.css +66 -0
- package/static/assets/css/tags.css +74 -0
- package/static/assets/css/xml.css +2 -0
- package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
- package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
- package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
- package/static/assets/ico/favicon.ico +0 -0
- package/static/assets/ico/favicon.png +0 -0
- package/static/assets/images/fhir-logo-www.png +0 -0
- package/static/assets/images/fhir-logo.png +0 -0
- package/static/assets/images/hl7-logo.png +0 -0
- package/static/assets/images/logo_ansinew.jpg +0 -0
- package/static/assets/images/search.png +0 -0
- package/static/assets/images/stripe.png +0 -0
- package/static/assets/images/target.png +0 -0
- package/static/assets/images/tx-registry-root.gif +0 -0
- package/static/assets/images/tx-registry.png +0 -0
- package/static/assets/images/tx-server.png +0 -0
- package/static/assets/images/tx-version.png +0 -0
- package/static/assets/js/bootstrap.min.js +6 -0
- package/static/assets/js/fhir-gw.js +259 -0
- package/static/assets/js/fhir.js +2 -0
- package/static/assets/js/html5shiv.js +8 -0
- package/static/assets/js/jcookie.js +96 -0
- package/static/assets/js/jquery-ui.min.js +6 -0
- package/static/assets/js/jquery.js +10716 -0
- package/static/assets/js/jquery.min.js +2 -0
- package/static/assets/js/jquery.ui.core.js +314 -0
- package/static/assets/js/jquery.ui.draggable.js +825 -0
- package/static/assets/js/jquery.ui.mouse.js +162 -0
- package/static/assets/js/jquery.ui.resizable.js +842 -0
- package/static/assets/js/jquery.ui.widget.js +268 -0
- package/static/assets/js/json2.js +487 -0
- package/static/assets/js/jtip.js +97 -0
- package/static/assets/js/respond.min.js +6 -0
- package/static/assets/js/statuspage.js +70 -0
- package/static/assets/js/xml.js +2 -0
- package/static/dist/js/bootstrap.js +1964 -0
- package/static/favicon.png +0 -0
- package/static/fhir.css +626 -0
- package/static/icon-fhir-16.png +0 -0
- package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
- package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
- package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
- package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
- package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
- package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
- package/static/images/ui-icons_222222_256x240.png +0 -0
- package/static/images/ui-icons_228ef1_256x240.png +0 -0
- package/static/images/ui-icons_ef8c08_256x240.png +0 -0
- package/static/images/ui-icons_ffd27a_256x240.png +0 -0
- package/static/images/ui-icons_ffffff_256x240.png +0 -0
- package/static/js/jquery.effects.blind.js +49 -0
- package/static/js/jquery.effects.bounce.js +78 -0
- package/static/js/jquery.effects.clip.js +54 -0
- package/static/js/jquery.effects.core.js +763 -0
- package/static/js/jquery.effects.drop.js +50 -0
- package/static/js/jquery.effects.explode.js +79 -0
- package/static/js/jquery.effects.fade.js +32 -0
- package/static/js/jquery.effects.fold.js +56 -0
- package/static/js/jquery.effects.highlight.js +50 -0
- package/static/js/jquery.effects.pulsate.js +51 -0
- package/static/js/jquery.effects.scale.js +178 -0
- package/static/js/jquery.effects.shake.js +57 -0
- package/static/js/jquery.effects.slide.js +50 -0
- package/static/js/jquery.effects.transfer.js +45 -0
- package/static/js/jquery.ui.accordion.js +611 -0
- package/static/js/jquery.ui.autocomplete.js +612 -0
- package/static/js/jquery.ui.button.js +416 -0
- package/static/js/jquery.ui.datepicker.js +1823 -0
- package/static/js/jquery.ui.dialog.js +878 -0
- package/static/js/jquery.ui.droppable.js +296 -0
- package/static/js/jquery.ui.position.js +252 -0
- package/static/js/jquery.ui.progressbar.js +109 -0
- package/static/js/jquery.ui.selectable.js +266 -0
- package/static/js/jquery.ui.slider.js +666 -0
- package/static/js/jquery.ui.sortable.js +1077 -0
- package/static/js/jquery.ui.tabs.js +758 -0
- package/stats.js +80 -0
- package/test-cache/vsac/vsac-valuesets.db +0 -0
- package/token/nginx_passport_setup.md +383 -0
- package/token/security_guide.md +294 -0
- package/token/token-template.html +330 -0
- package/token/token.js +1300 -0
- package/translations/Messages.properties +1510 -0
- package/translations/Messages_ar.properties +1399 -0
- package/translations/Messages_de.properties +836 -0
- package/translations/Messages_es.properties +737 -0
- package/translations/Messages_fr.properties +1 -0
- package/translations/Messages_ja.properties +893 -0
- package/translations/Messages_nl.properties +1357 -0
- package/translations/Messages_pt.properties +1302 -0
- package/translations/Messages_ru.properties +1 -0
- package/translations/Messages_uz.properties +1 -0
- package/translations/Messages_zh.properties +1 -0
- package/translations/rendering-phrases.properties +1128 -0
- package/translations/rendering-phrases_ar.properties +1091 -0
- package/translations/rendering-phrases_de.properties +6 -0
- package/translations/rendering-phrases_es.properties +6 -0
- package/translations/rendering-phrases_fr.properties +624 -0
- package/translations/rendering-phrases_ja.properties +21 -0
- package/translations/rendering-phrases_nl.properties +970 -0
- package/translations/rendering-phrases_pt.properties +1020 -0
- package/translations/rendering-phrases_ru.properties +1094 -0
- package/translations/rendering-phrases_uz.properties +1 -0
- package/translations/rendering-phrases_zh.properties +1 -0
- package/tx/README.md +418 -0
- package/tx/cm/cm-api.js +110 -0
- package/tx/cm/cm-database.js +735 -0
- package/tx/cm/cm-package.js +325 -0
- package/tx/cs/cs-api.js +789 -0
- package/tx/cs/cs-areacode.js +615 -0
- package/tx/cs/cs-country.js +1110 -0
- package/tx/cs/cs-cpt.js +785 -0
- package/tx/cs/cs-cs.js +1579 -0
- package/tx/cs/cs-currency.js +539 -0
- package/tx/cs/cs-db.js +1321 -0
- package/tx/cs/cs-hgvs.js +329 -0
- package/tx/cs/cs-lang.js +465 -0
- package/tx/cs/cs-loinc.js +1485 -0
- package/tx/cs/cs-mimetypes.js +238 -0
- package/tx/cs/cs-ndc.js +704 -0
- package/tx/cs/cs-omop.js +1025 -0
- package/tx/cs/cs-provider-api.js +43 -0
- package/tx/cs/cs-provider-list.js +37 -0
- package/tx/cs/cs-rxnorm.js +808 -0
- package/tx/cs/cs-snomed.js +1102 -0
- package/tx/cs/cs-ucum.js +514 -0
- package/tx/cs/cs-unii.js +271 -0
- package/tx/cs/cs-uri.js +218 -0
- package/tx/cs/cs-usstates.js +305 -0
- package/tx/dev.fhir.org.yml +14 -0
- package/tx/fixtures/test-cases-setup.json +18 -0
- package/tx/fixtures/test-cases.yml +16 -0
- package/tx/html/codesystem-operations.liquid +25 -0
- package/tx/html/home-metrics.liquid +247 -0
- package/tx/html/operations-form.liquid +148 -0
- package/tx/html/search-form.liquid +62 -0
- package/tx/html/tx-template.html +133 -0
- package/tx/html/valueset-operations.liquid +54 -0
- package/tx/importers/atc-to-fhir.js +316 -0
- package/tx/importers/import-loinc.module.js +1536 -0
- package/tx/importers/import-ndc.module.js +1088 -0
- package/tx/importers/import-rxnorm.module.js +898 -0
- package/tx/importers/import-sct.module.js +2457 -0
- package/tx/importers/import-unii.module.js +601 -0
- package/tx/importers/readme.md +453 -0
- package/tx/importers/subset-loinc.module.js +1081 -0
- package/tx/importers/subset-rxnorm.module.js +938 -0
- package/tx/importers/tx-import-base.js +351 -0
- package/tx/importers/tx-import-settings.js +310 -0
- package/tx/importers/tx-import.js +357 -0
- package/tx/library/canonical-resource.js +88 -0
- package/tx/library/capabilitystatement.js +292 -0
- package/tx/library/codesystem.js +774 -0
- package/tx/library/conceptmap.js +568 -0
- package/tx/library/designations.js +932 -0
- package/tx/library/errors.js +77 -0
- package/tx/library/extensions.js +117 -0
- package/tx/library/namingsystem.js +322 -0
- package/tx/library/operation-outcome.js +127 -0
- package/tx/library/parameters.js +105 -0
- package/tx/library/renderer.js +1559 -0
- package/tx/library/terminologycapabilities.js +418 -0
- package/tx/library/ucum-parsers.js +1029 -0
- package/tx/library/ucum-service.js +370 -0
- package/tx/library/ucum-types.js +1099 -0
- package/tx/library/valueset.js +543 -0
- package/tx/library.js +676 -0
- package/tx/ocl/cm-ocl.js +106 -0
- package/tx/ocl/cs-ocl.js +39 -0
- package/tx/ocl/vs-ocl.js +105 -0
- package/tx/operation-context.js +568 -0
- package/tx/params.js +613 -0
- package/tx/provider.js +403 -0
- package/tx/sct/ecl.js +1560 -0
- package/tx/sct/expressions.js +2077 -0
- package/tx/sct/structures.js +1396 -0
- package/tx/tx-html.js +1063 -0
- package/tx/tx.fhir.org.yml +39 -0
- package/tx/tx.js +927 -0
- package/tx/vs/vs-api.js +112 -0
- package/tx/vs/vs-database.js +786 -0
- package/tx/vs/vs-package.js +358 -0
- package/tx/vs/vs-vsac.js +366 -0
- package/tx/workers/batch-validate.js +129 -0
- package/tx/workers/batch.js +361 -0
- package/tx/workers/closure.js +32 -0
- package/tx/workers/expand.js +1845 -0
- package/tx/workers/lookup.js +407 -0
- package/tx/workers/metadata.js +467 -0
- package/tx/workers/operations.js +34 -0
- package/tx/workers/read.js +164 -0
- package/tx/workers/search.js +384 -0
- package/tx/workers/subsumes.js +334 -0
- package/tx/workers/translate.js +492 -0
- package/tx/workers/validate.js +2504 -0
- package/tx/workers/worker.js +904 -0
- package/tx/xml/capabilitystatement-xml.js +63 -0
- package/tx/xml/codesystem-xml.js +62 -0
- package/tx/xml/conceptmap-xml.js +65 -0
- package/tx/xml/namingsystem-xml.js +65 -0
- package/tx/xml/operationoutcome-xml.js +127 -0
- package/tx/xml/parameters-xml.js +312 -0
- package/tx/xml/terminologycapabilities-xml.js +64 -0
- package/tx/xml/valueset-xml.js +64 -0
- package/tx/xml/xml-base.js +603 -0
- package/vcl/vcl-parser.js +1098 -0
- package/vcl/vcl.js +253 -0
- package/windows-install.js +19 -0
- package/xig/xig-template.html +124 -0
- 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
|
+
'&': '&',
|
|
231
|
+
'<': '<',
|
|
232
|
+
'>': '>',
|
|
233
|
+
'"': '"',
|
|
234
|
+
"'": ''',
|
|
235
|
+
'/': '/',
|
|
236
|
+
'`': '`',
|
|
237
|
+
'=': '='
|
|
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;
|