fhirsmith 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +13 -4
- package/config-template.json +16 -0
- package/configurations/readme.md +1 -0
- package/extension-tracker/extension-tracker-template.html +124 -0
- package/extension-tracker/extension-tracker.js +697 -0
- package/extension-tracker/readme.md +63 -0
- package/folder/folder.js +305 -0
- package/folder/readme.md +57 -0
- package/library/html-server.js +8 -2
- package/package.json +4 -2
- package/packages/packages.js +8 -8
- package/server.js +55 -3
- package/tx/cs/cs-snomed.js +5 -3
- package/tx/html/conceptmap-operations.liquid +19 -0
- package/tx/library/extensions.js +6 -2
- package/tx/library/renderer.js +572 -3
- package/tx/ocl/cache/cache-paths.cjs +4 -5
- package/tx/ocl/cm-ocl.cjs +4 -1
- package/tx/ocl/cs-ocl.cjs +9 -9
- package/tx/ocl/vs-ocl.cjs +14 -5
- package/tx/tx-html.js +48 -3
- package/tx/workers/expand.js +23 -12
- package/tx/workers/read.js +23 -8
- package/tx/workers/search.js +62 -16
- package/tx/workers/validate.js +11 -6
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# FHIRsmith Extension Tracker Module
|
|
2
|
+
|
|
3
|
+
Tracks extensions, profiles, and extension usage across FHIR Implementation Guides. IGs submit data via POST, and the module provides browsable views of the collected data.
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
"ext-tracker": {
|
|
9
|
+
"enabled": true,
|
|
10
|
+
"database": "/var/fhir/data/extension-tracker.db",
|
|
11
|
+
"url": "/ext-tracker"
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- **database**: path to the SQLite database file (auto-created if missing)
|
|
16
|
+
- **url** (optional): URL base to mount at (defaults to `/ext-tracker`)
|
|
17
|
+
|
|
18
|
+
## POST — Submit IG Data
|
|
19
|
+
|
|
20
|
+
POST JSON to the module root. Required fields: `package`, `version`, `fhirVersion`. Optional: `jurisdiction`, `extensions`, `profiles`, `usage`.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
POST /ext-tracker
|
|
24
|
+
Content-Type: application/json
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
"package": "hl7.fhir.uv.tx-ecosystem",
|
|
28
|
+
"version": "1.9.1",
|
|
29
|
+
"fhirVersion": "5.0.0",
|
|
30
|
+
"jurisdiction": "001",
|
|
31
|
+
"extensions": [
|
|
32
|
+
{ "url": "http://example.org/ext", "title": "My Extension", "types": ["string"] }
|
|
33
|
+
],
|
|
34
|
+
"profiles": {
|
|
35
|
+
"Patient": [
|
|
36
|
+
{ "url": "http://example.org/profile", "title": "My Profile" }
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"usage": {
|
|
40
|
+
"http://example.org/ext": ["Patient", "Patient.name"]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Submitting a new version for an existing package replaces the old data entirely — only the latest submission per package is kept.
|
|
46
|
+
|
|
47
|
+
Duplicate types in extensions are automatically deduplicated.
|
|
48
|
+
|
|
49
|
+
## GET — Browse Data
|
|
50
|
+
|
|
51
|
+
- `/ext-tracker` — Summary dashboard with package list
|
|
52
|
+
- `/ext-tracker/extensions` — All extensions, filterable by `?package=`
|
|
53
|
+
- `/ext-tracker/profiles` — All profiles, filterable by `?resource=` and `?package=`
|
|
54
|
+
- `/ext-tracker/usage` — Extension usage, filterable by `?url=` and `?location=`
|
|
55
|
+
- `/ext-tracker/package/:name` — Detail page for a specific package
|
|
56
|
+
|
|
57
|
+
## Dependencies
|
|
58
|
+
|
|
59
|
+
Requires `better-sqlite3` (`npm install better-sqlite3`).
|
|
60
|
+
|
|
61
|
+
## Database
|
|
62
|
+
|
|
63
|
+
The SQLite database is auto-created on first run with five tables: `packages`, `extensions`, `extension_types`, `profiles`, and `usages`. Foreign keys cascade deletes, so removing a package cleans up all related data.
|
package/folder/folder.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const USERS_FILE = '.users.json';
|
|
5
|
+
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
6
|
+
const SAFE_NAME = /^[a-zA-Z0-9._-]+$/;
|
|
7
|
+
const AUTH_FAIL_DELAY_MS = 5000;
|
|
8
|
+
|
|
9
|
+
function escapeHtml(str) {
|
|
10
|
+
return String(str)
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class FolderModule {
|
|
19
|
+
constructor(stats) {
|
|
20
|
+
this.folders = [];
|
|
21
|
+
this.stats = stats;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
initialize(config, app) {
|
|
25
|
+
this.folders = [];
|
|
26
|
+
|
|
27
|
+
const folderConfigs = config.folders || [];
|
|
28
|
+
for (const fc of folderConfigs) {
|
|
29
|
+
if (fc.enabled === false) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!fc.folder || !fc.url || !fc.name) {
|
|
33
|
+
console.log(`Folder config entry missing required fields (name, folder, url) - skipping`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const rootDir = path.resolve(fc.folder);
|
|
37
|
+
if (!fs.existsSync(rootDir)) {
|
|
38
|
+
console.log(`Folder path ${rootDir} does not exist - skipping "${fc.name}"`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const urlBase = fc.url.startsWith('/') ? fc.url : '/' + fc.url;
|
|
43
|
+
const router = express.Router();
|
|
44
|
+
|
|
45
|
+
// GET - serve files and directory listings
|
|
46
|
+
router.get(urlBase + '/{*subpath}', (req, res) => {
|
|
47
|
+
this.handleGet(req, res, rootDir, urlBase);
|
|
48
|
+
});
|
|
49
|
+
router.get(urlBase, (req, res) => {
|
|
50
|
+
this.handleGet(req, res, rootDir, urlBase);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// PUT - upload with basic auth (express.raw captures body before any global JSON parser)
|
|
54
|
+
router.put(urlBase + '/{*subpath}', express.raw({ type: '*/*', limit: MAX_UPLOAD_BYTES }), (req, res) => {
|
|
55
|
+
this.handlePut(req, res, rootDir, urlBase);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
app.use('/', router);
|
|
59
|
+
this.folders.push({ name: fc.name, folder: rootDir, url: urlBase });
|
|
60
|
+
console.log(`Folder module: serving "${fc.name}" from ${rootDir} at ${urlBase}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this.folders.length === 0) {
|
|
64
|
+
console.log('Folder module: no folders configured');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
handleGet(req, res, rootDir, urlBase) {
|
|
69
|
+
this.stats.countRequest('search', 0);
|
|
70
|
+
const subPath = req.path.substring(urlBase.length) || '/';
|
|
71
|
+
const safePath = path.normalize(subPath);
|
|
72
|
+
if (safePath.includes('..')) {
|
|
73
|
+
return res.status(403).send('Forbidden');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fullPath = path.join(rootDir, safePath);
|
|
77
|
+
|
|
78
|
+
// never serve .users.json
|
|
79
|
+
if (path.basename(fullPath) === '.users.json') {
|
|
80
|
+
return res.status(403).send('Forbidden');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!fs.existsSync(fullPath)) {
|
|
84
|
+
return res.status(404).send('Not found');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const stat = fs.statSync(fullPath);
|
|
88
|
+
if (stat.isFile()) {
|
|
89
|
+
return res.sendFile(fullPath);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (stat.isDirectory()) {
|
|
93
|
+
return this.sendDirectoryListing(res, fullPath, req.path);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return res.status(404).send('Not found');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
sendDirectoryListing(res, dirPath, requestPath) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
102
|
+
const urlPath = requestPath.endsWith('/') ? requestPath : requestPath + '/';
|
|
103
|
+
|
|
104
|
+
// separate dirs and files, exclude .users.json
|
|
105
|
+
const dirs = entries.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
|
|
106
|
+
const files = entries.filter(e => e.isFile() && e.name !== '.users.json').sort((a, b) => a.name.localeCompare(b.name));
|
|
107
|
+
|
|
108
|
+
const safeRequestPath = escapeHtml(requestPath);
|
|
109
|
+
let html = `<html><head><title>Index of ${safeRequestPath}</title></head><body>`;
|
|
110
|
+
html += `<h1>Index of ${safeRequestPath}</h1><pre>`;
|
|
111
|
+
|
|
112
|
+
// parent directory link (if not at mount root)
|
|
113
|
+
if (requestPath.split('/').filter(Boolean).length > 1) {
|
|
114
|
+
html += `<a href="${urlPath}..">../</a>\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const dir of dirs) {
|
|
118
|
+
const safeDirName = escapeHtml(dir.name);
|
|
119
|
+
html += `<a href="${urlPath}${encodeURIComponent(dir.name)}/">${safeDirName}/</a>\n`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
const stat = fs.statSync(path.join(dirPath, file.name));
|
|
124
|
+
const size = this.formatSize(stat.size);
|
|
125
|
+
const safeFileName = escapeHtml(file.name);
|
|
126
|
+
html += `<a href="${urlPath}${encodeURIComponent(file.name)}">${safeFileName}</a>${' '.repeat(Math.max(1, 60 - file.name.length))}${size}\n`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
html += '</pre></body></html>';
|
|
130
|
+
this.stats.countRequest('folder', Date.now() - start);
|
|
131
|
+
res.type('html').send(html);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
formatSize(bytes) {
|
|
135
|
+
if (bytes < 1024) return bytes + ' B';
|
|
136
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
137
|
+
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
138
|
+
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
handlePut(req, res, rootDir, urlBase) {
|
|
142
|
+
this.stats.countRequest('submit', 0);
|
|
143
|
+
const subPath = req.path.substring(urlBase.length);
|
|
144
|
+
const safePath = path.normalize(subPath);
|
|
145
|
+
if (safePath.includes('..')) {
|
|
146
|
+
return res.status(403).send('Forbidden');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const fullPath = path.join(rootDir, safePath);
|
|
150
|
+
|
|
151
|
+
// must be a file path, not a directory
|
|
152
|
+
if (safePath === '/' || safePath === '') {
|
|
153
|
+
return res.status(400).send('Cannot PUT to directory root');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// validate every path segment: alphanumeric, dots, dashes, underscores only
|
|
157
|
+
// split on both / and \ since path.normalize uses \ on Windows
|
|
158
|
+
const segments = safePath.split(/[/\\]/).filter(Boolean);
|
|
159
|
+
for (const seg of segments) {
|
|
160
|
+
if (!SAFE_NAME.test(seg)) {
|
|
161
|
+
return res.status(400).send('Invalid path: names may only contain letters, numbers, dots, dashes and underscores');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// only allow .zip files
|
|
166
|
+
const filename = path.basename(fullPath);
|
|
167
|
+
if (!filename.toLowerCase().endsWith('.zip')) {
|
|
168
|
+
return res.status(400).send('Only .zip files may be uploaded');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// check content-length before reading body
|
|
172
|
+
const contentLength = parseInt(req.headers['content-length'], 10);
|
|
173
|
+
if (contentLength > MAX_UPLOAD_BYTES) {
|
|
174
|
+
return res.status(413).send(`File too large (limit ${MAX_UPLOAD_BYTES / 1024 / 1024} MB)`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// authenticate
|
|
178
|
+
const credentials = this.parseBasicAuth(req);
|
|
179
|
+
if (!credentials) {
|
|
180
|
+
res.set('WWW-Authenticate', 'Basic realm="Folder Upload"');
|
|
181
|
+
return res.status(401).send('Authentication required');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!this.checkUser(rootDir, path.dirname(fullPath), credentials.username, credentials.password)) {
|
|
185
|
+
// deliberate delay to slow down brute-force attempts
|
|
186
|
+
return setTimeout(() => {
|
|
187
|
+
res.set('WWW-Authenticate', 'Basic realm="Folder Upload"');
|
|
188
|
+
res.status(403).send('Access denied');
|
|
189
|
+
}, AUTH_FAIL_DELAY_MS);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.getBody(req).then(body => {
|
|
193
|
+
// double-check actual body size
|
|
194
|
+
if (body.length > MAX_UPLOAD_BYTES) {
|
|
195
|
+
return res.status(413).send(`File too large (limit ${MAX_UPLOAD_BYTES / 1024 / 1024} MB)`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ensure parent directory exists
|
|
199
|
+
const parentDir = path.dirname(fullPath);
|
|
200
|
+
if (!fs.existsSync(parentDir)) {
|
|
201
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// write the file
|
|
205
|
+
fs.writeFileSync(fullPath, body);
|
|
206
|
+
|
|
207
|
+
// if it's main.zip or master.zip, also save as default.zip
|
|
208
|
+
const basename = path.basename(fullPath).toLowerCase();
|
|
209
|
+
if (basename === 'main.zip' || basename === 'master.zip') {
|
|
210
|
+
const defaultPath = path.join(parentDir, 'default.zip');
|
|
211
|
+
fs.copyFileSync(fullPath, defaultPath);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return res.status(200).send('OK');
|
|
215
|
+
}).catch(err => {
|
|
216
|
+
console.log(`Folder module: PUT error for ${fullPath}: ${err.message}`);
|
|
217
|
+
return res.status(500).send('Upload failed');
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// get request body as a Buffer, whether or not global middleware already parsed it
|
|
222
|
+
getBody(req) {
|
|
223
|
+
// if global middleware already parsed it, use that
|
|
224
|
+
if (Buffer.isBuffer(req.body)) {
|
|
225
|
+
return Promise.resolve(req.body);
|
|
226
|
+
}
|
|
227
|
+
if (req.body && typeof req.body === 'object') {
|
|
228
|
+
return Promise.resolve(Buffer.from(JSON.stringify(req.body)));
|
|
229
|
+
}
|
|
230
|
+
if (req.body) {
|
|
231
|
+
return Promise.resolve(Buffer.from(req.body));
|
|
232
|
+
}
|
|
233
|
+
// no middleware parsed it — read from stream
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
const chunks = [];
|
|
236
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
237
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
238
|
+
req.on('error', reject);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
parseBasicAuth(req) {
|
|
243
|
+
const header = req.headers.authorization;
|
|
244
|
+
if (!header || !header.startsWith('Basic ')) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const decoded = Buffer.from(header.substring(6), 'base64').toString('utf-8');
|
|
248
|
+
const colon = decoded.indexOf(':');
|
|
249
|
+
if (colon < 0) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
username: decoded.substring(0, colon),
|
|
254
|
+
password: decoded.substring(colon + 1)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// walk up from the target directory to rootDir looking for .users.json
|
|
259
|
+
checkUser(rootDir, dir, username, password) {
|
|
260
|
+
let current = path.resolve(dir);
|
|
261
|
+
const root = path.resolve(rootDir);
|
|
262
|
+
|
|
263
|
+
while (true) {
|
|
264
|
+
const usersPath = path.join(current, USERS_FILE);
|
|
265
|
+
if (fs.existsSync(usersPath)) {
|
|
266
|
+
try {
|
|
267
|
+
const users = JSON.parse(fs.readFileSync(usersPath, 'utf-8'));
|
|
268
|
+
if (users[username] && users[username] === password) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
} catch (e) {
|
|
272
|
+
// malformed json, keep walking up
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (current === root) {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const parent = path.dirname(current);
|
|
281
|
+
if (parent === current) {
|
|
282
|
+
break; // filesystem root - stop
|
|
283
|
+
}
|
|
284
|
+
current = parent;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
shutdown() {
|
|
291
|
+
// nothing to clean up
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
getStatus() {
|
|
295
|
+
return {
|
|
296
|
+
folders: this.folders.map(f => ({
|
|
297
|
+
name: f.name,
|
|
298
|
+
folder: f.folder,
|
|
299
|
+
url: f.url
|
|
300
|
+
}))
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = FolderModule;
|
package/folder/readme.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# FHIRsmith Folder Module
|
|
2
|
+
|
|
3
|
+
Serves static files from one or more directories, with optional authenticated upload via PUT.
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
Add a `folder` entry under `modules` in your config, following the standard FHIRsmith module pattern:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
"folder": {
|
|
11
|
+
"enabled": true,
|
|
12
|
+
"folders": [
|
|
13
|
+
{ "name": "packages", "folder": "/var/fhir/packages", "url": "/packages" },
|
|
14
|
+
{ "name": "igs", "folder": "/var/fhir/igs", "url": "/igs", "enabled": false }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The top-level `enabled` controls whether the module loads at all. Each folder entry also supports its own `enabled: false` to disable individual folders without removing them from the config.
|
|
20
|
+
|
|
21
|
+
- **name**: display name (used in status and logging)
|
|
22
|
+
- **folder**: absolute path to the directory to serve
|
|
23
|
+
- **url**: URL path to mount at
|
|
24
|
+
- **enabled** (optional): set to `false` to skip this entry
|
|
25
|
+
|
|
26
|
+
Directories that don't exist at startup are skipped with a warning. Zero folders is valid.
|
|
27
|
+
|
|
28
|
+
## GET — File Serving
|
|
29
|
+
|
|
30
|
+
Any GET request under the configured URL serves files directly. If the path is a directory, it returns an HTML listing showing subdirectories first, then files with sizes.
|
|
31
|
+
|
|
32
|
+
## PUT — Authenticated Upload
|
|
33
|
+
|
|
34
|
+
PUT writes a file to the path specified in the URL. The parent directory is created automatically if it doesn't exist.
|
|
35
|
+
|
|
36
|
+
All PUT requests require HTTP Basic authentication. Credentials are checked against `.users.json` files (see below).
|
|
37
|
+
|
|
38
|
+
Special case: uploading `main.zip` or `master.zip` automatically creates a copy named `default.zip` in the same directory.
|
|
39
|
+
|
|
40
|
+
## .users.json
|
|
41
|
+
|
|
42
|
+
Authentication for PUT is controlled by `.users.json` files placed anywhere in the served directory tree. Format is a simple JSON object mapping usernames to passwords:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"grahame": "secretpassword",
|
|
47
|
+
"ci-bot": "buildtoken123"
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
When a PUT comes in, the module walks up from the target directory to the folder root looking for a `.users.json` file. The first one found that contains a matching username and password grants access. If no match is found at any level, the request is rejected with 403.
|
|
52
|
+
|
|
53
|
+
This means you can put a `.users.json` at the root to cover everything, or use per-subdirectory files to grant more specific access. `.users.json` files are never served by GET.
|
|
54
|
+
|
|
55
|
+
## Dependencies
|
|
56
|
+
|
|
57
|
+
No additional npm dependencies required.
|
package/library/html-server.js
CHANGED
|
@@ -100,20 +100,26 @@ class HtmlServer {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
sendErrorResponse(res, templateName, error, statusCode = 500) {
|
|
103
|
+
if (res.headersSent) {
|
|
104
|
+
this.log.error('[HtmlServer] Cannot send error response - headers already sent:', error.message || error);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
103
107
|
const errorContent = `
|
|
104
108
|
<div class="alert alert-danger">
|
|
105
109
|
<h4>Error</h4>
|
|
106
110
|
<p>${escape(error.message || error)}</p>
|
|
107
111
|
</div>
|
|
108
112
|
`;
|
|
109
|
-
|
|
113
|
+
|
|
110
114
|
try {
|
|
111
115
|
const html = this.renderPage(templateName, 'Error', errorContent);
|
|
112
116
|
res.status(statusCode).setHeader('Content-Type', 'text/html');
|
|
113
117
|
res.send(html);
|
|
114
118
|
} catch (renderError) {
|
|
115
119
|
this.log.error('[HtmlServer] Error rendering error page:', renderError);
|
|
116
|
-
res.
|
|
120
|
+
if (!res.headersSent) {
|
|
121
|
+
res.status(statusCode).send(`<h1>Error</h1><p>Failed to render error page: ${escape(renderError.message)}</p>`);
|
|
122
|
+
}
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
125
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"engines": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"axios": "^1.13.4",
|
|
33
33
|
"base45": "^3.0.0",
|
|
34
34
|
"bcrypt": "^6.0.0",
|
|
35
|
+
"better-sqlite3": "^12.8.0",
|
|
35
36
|
"cbor": "^9.0.1",
|
|
36
37
|
"chalk": "^4.1.2",
|
|
37
38
|
"chokidar": "^4.0.3",
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"fhir-validator-wrapper": "1.2.2",
|
|
48
49
|
"fhirpath": "^4.8.3",
|
|
49
50
|
"fs-extra": "^11.3.3",
|
|
51
|
+
"ini": "^6.0.0",
|
|
50
52
|
"inquirer": "^8.2.5",
|
|
51
53
|
"liquidjs": "^10.24.0",
|
|
52
54
|
"lusca": "^1.7.0",
|
|
@@ -116,4 +118,4 @@
|
|
|
116
118
|
"url": "https://github.com/HealthIntersections/fhirsmith/issues"
|
|
117
119
|
},
|
|
118
120
|
"homepage": "https://github.com/HealthIntersections/fhirsmith#readme"
|
|
119
|
-
}
|
|
121
|
+
}
|
package/packages/packages.js
CHANGED
|
@@ -1721,10 +1721,10 @@ class PackagesModule {
|
|
|
1721
1721
|
}
|
|
1722
1722
|
|
|
1723
1723
|
const dependency = row.Dependency;
|
|
1724
|
-
const
|
|
1725
|
-
if (
|
|
1726
|
-
const depName = dependency.substring(0,
|
|
1727
|
-
const depVersion = dependency.substring(
|
|
1724
|
+
const atIndex = dependency.indexOf('@');
|
|
1725
|
+
if (atIndex > 0) {
|
|
1726
|
+
const depName = dependency.substring(0, atIndex);
|
|
1727
|
+
const depVersion = dependency.substring(atIndex + 1);
|
|
1728
1728
|
deps[row.PackageVersionKey][depName] = depVersion;
|
|
1729
1729
|
}
|
|
1730
1730
|
}
|
|
@@ -2493,10 +2493,10 @@ class PackagesModule {
|
|
|
2493
2493
|
}
|
|
2494
2494
|
|
|
2495
2495
|
// Extract dependency name and version
|
|
2496
|
-
const
|
|
2497
|
-
if (
|
|
2498
|
-
const depName = dependency.substring(0,
|
|
2499
|
-
const depVersion = dependency.substring(
|
|
2496
|
+
const atIndex = dependency.indexOf('@');
|
|
2497
|
+
if (atIndex > 0) {
|
|
2498
|
+
const depName = dependency.substring(0, atIndex);
|
|
2499
|
+
const depVersion = dependency.substring(atIndex + 1);
|
|
2500
2500
|
const depMajorMinor = this.getMajorMinorVersion(depVersion);
|
|
2501
2501
|
const depRef = `${depName}#${depMajorMinor}`;
|
|
2502
2502
|
|
package/server.js
CHANGED
|
@@ -59,6 +59,8 @@ const TXModule = require('./tx/tx.js');
|
|
|
59
59
|
const htmlServer = require('./library/html-server');
|
|
60
60
|
const ServerStats = require("./stats");
|
|
61
61
|
const {Liquid} = require("liquidjs");
|
|
62
|
+
const FolderModule = require("./folder/folder");
|
|
63
|
+
const ExtensionTrackerModule = require("./extension-tracker/extension-tracker");
|
|
62
64
|
|
|
63
65
|
htmlServer.useLog(serverLog);
|
|
64
66
|
|
|
@@ -195,6 +197,17 @@ async function initializeModules() {
|
|
|
195
197
|
}
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
|
|
201
|
+
if (config.modules?.['ext-tracker']?.enabled) {
|
|
202
|
+
try {
|
|
203
|
+
serverLog.info('Initializing module: ext-tracker...');
|
|
204
|
+
modules.extTracker = new ExtensionTrackerModule(stats);
|
|
205
|
+
await modules.extTracker.initialize(config.modules['ext-tracker'], app);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
serverLog.error('Failed to initialize extension tracker module:', error);
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
198
211
|
// Initialize TX module
|
|
199
212
|
// Note: TX module registers its own endpoints directly on the app
|
|
200
213
|
// because it supports multiple endpoints at different paths
|
|
@@ -208,6 +221,18 @@ async function initializeModules() {
|
|
|
208
221
|
throw error;
|
|
209
222
|
}
|
|
210
223
|
}
|
|
224
|
+
|
|
225
|
+
if (config.modules?.folder?.enabled) {
|
|
226
|
+
try {
|
|
227
|
+
serverLog.info('Initializing module: folder...');
|
|
228
|
+
modules.folder = new FolderModule(stats);
|
|
229
|
+
await modules.folder.initialize(config.modules.folder, app);
|
|
230
|
+
// mount the router
|
|
231
|
+
} catch (error) {
|
|
232
|
+
serverLog.error('Failed to initialize folder module:', error);
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
211
236
|
}
|
|
212
237
|
|
|
213
238
|
async function loadTemplates() {
|
|
@@ -312,6 +337,32 @@ async function buildRootPageContent() {
|
|
|
312
337
|
content += '</li>';
|
|
313
338
|
}
|
|
314
339
|
|
|
340
|
+
if (config.modules?.['ext-tracker']?.enabled) {
|
|
341
|
+
mc++;
|
|
342
|
+
content += '<li class="list-group-item">';
|
|
343
|
+
content += '<a href="/ext-tracker" class="text-decoration-none">Extension Tracker</a>: ';
|
|
344
|
+
content += 'View of Extension Usage';
|
|
345
|
+
content += '</li>';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (config.modules.folder && config.modules.folder.enabled) {
|
|
349
|
+
content += '<li class="list-group-item">';
|
|
350
|
+
content += '<strong>Cache Folder</strong>: ';
|
|
351
|
+
content += 'Cache Folder for Kindling';
|
|
352
|
+
const folders = config.modules.folder.folders || [];
|
|
353
|
+
content += '<ul class="mt-2 mb-0">';
|
|
354
|
+
for (const fc of folders) {
|
|
355
|
+
if (fc.enabled === false) continue;
|
|
356
|
+
mc++;
|
|
357
|
+
content += '<li>';
|
|
358
|
+
content += `<a href="${fc.url}" class="text-decoration-none">${fc.name}</a>: `;
|
|
359
|
+
content += 'File folder with write control';
|
|
360
|
+
content += '</li>';
|
|
361
|
+
}
|
|
362
|
+
content += '</ul>';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
315
366
|
if (config.modules.tx && config.modules.tx.enabled) {
|
|
316
367
|
content += '<li class="list-group-item">';
|
|
317
368
|
content += '<strong>TX Terminology Server</strong>: ';
|
|
@@ -433,7 +484,7 @@ app.get('/', async (req, res) => {
|
|
|
433
484
|
about
|
|
434
485
|
};
|
|
435
486
|
|
|
436
|
-
const html = htmlServer.renderPage('root', escape(config.hostName)
|
|
487
|
+
const html = htmlServer.renderPage('root', config.hostName ? escape(config.hostName) : 'FHIRsmith Server', content, stats);
|
|
437
488
|
res.setHeader('Content-Type', 'text/html');
|
|
438
489
|
res.send(html);
|
|
439
490
|
return;
|
|
@@ -442,8 +493,9 @@ app.get('/', async (req, res) => {
|
|
|
442
493
|
htmlServer.sendErrorResponse(res, 'root', error);
|
|
443
494
|
return;
|
|
444
495
|
}
|
|
496
|
+
} else {
|
|
497
|
+
return serveFhirsmithHome(req, res);
|
|
445
498
|
}
|
|
446
|
-
return serveFhirsmithHome(req, res);
|
|
447
499
|
});
|
|
448
500
|
|
|
449
501
|
app.get('/fhirsmith', (req, res) => serveFhirsmithHome(req, res));
|
|
@@ -622,7 +674,7 @@ async function serveFhirsmithHome(req, res) {
|
|
|
622
674
|
processingTime: Date.now() - startTime
|
|
623
675
|
};
|
|
624
676
|
|
|
625
|
-
const html = htmlServer.renderPage('root', escape(config.hostName)
|
|
677
|
+
const html = htmlServer.renderPage('root', config.hostName ? escape(config.hostName) : 'FHIRsmith Server', content, stats);
|
|
626
678
|
res.setHeader('Content-Type', 'text/html');
|
|
627
679
|
res.send(html);
|
|
628
680
|
return;
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -410,9 +410,9 @@ class SnomedServices {
|
|
|
410
410
|
|
|
411
411
|
if (matchFound) {
|
|
412
412
|
// Calculate priority based on match quality
|
|
413
|
-
if (term === searchText.toLowerCase()) {
|
|
413
|
+
if (term === searchText.filter.toLowerCase()) {
|
|
414
414
|
priority = 100; // Exact match
|
|
415
|
-
} else if (term.startsWith(searchText.toLowerCase())) {
|
|
415
|
+
} else if (term.startsWith(searchText.filter.toLowerCase())) {
|
|
416
416
|
priority = 50; // Prefix match
|
|
417
417
|
} else {
|
|
418
418
|
priority = 10; // Contains match
|
|
@@ -969,7 +969,9 @@ class SnomedProvider extends BaseCSServices {
|
|
|
969
969
|
|
|
970
970
|
// Search filter
|
|
971
971
|
async searchFilter(filterContext, filter, sort) {
|
|
972
|
-
|
|
972
|
+
let f = this.sct.searchFilter(filter, false, sort);
|
|
973
|
+
filterContext.filters.push(f);
|
|
974
|
+
return f;
|
|
973
975
|
}
|
|
974
976
|
|
|
975
977
|
// Subsumption testing
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
<div class="operation-form" style="margin-bottom: 15px;">
|
|
3
|
+
<strong>Translate</strong>
|
|
4
|
+
<form method="get" action="$translate" style="margin-left: 10px; margin-top: 5px;">
|
|
5
|
+
<input type="hidden" name="url" value="{{ url }}"/>
|
|
6
|
+
<table class="grid" cellpadding="0" cellspacing="0">
|
|
7
|
+
<tr>
|
|
8
|
+
<td>System:</td><td><select name="sourceSystem">{{ sources }}</select></td>
|
|
9
|
+
</tr>
|
|
10
|
+
<tr>
|
|
11
|
+
<td>Code:</td><td><select name="sourceCode">{{ codes }}</select></td>
|
|
12
|
+
</tr>
|
|
13
|
+
<tr>
|
|
14
|
+
<td>Target:</td><td><select name="targetSystem">{{ targets }}</select></td>
|
|
15
|
+
</tr>
|
|
16
|
+
</table>
|
|
17
|
+
<button type="submit" class="btn btn-sm btn-primary">Translate</button>
|
|
18
|
+
</form>
|
|
19
|
+
</div>
|
package/tx/library/extensions.js
CHANGED
|
@@ -110,14 +110,18 @@ const Extensions = {
|
|
|
110
110
|
if (!exp.extension) {
|
|
111
111
|
exp.extension = [];
|
|
112
112
|
}
|
|
113
|
-
|
|
113
|
+
let ext = { url : url, valueBoolean : b };
|
|
114
|
+
exp.extension.push(ext);
|
|
115
|
+
return ext;
|
|
114
116
|
},
|
|
115
117
|
|
|
116
118
|
addString(exp, url, s) {
|
|
117
119
|
if (!exp.extension) {
|
|
118
120
|
exp.extension = [];
|
|
119
121
|
}
|
|
120
|
-
|
|
122
|
+
let ext = { url : url, valueString : s };
|
|
123
|
+
exp.extension.push(ext);
|
|
124
|
+
return ext;
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
|