fhirsmith 0.9.6 → 0.10.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 +51 -0
- package/library/folder-content-loader.js +91 -0
- package/npmprojector/npmprojector.js +2 -6
- package/package.json +1 -1
- package/packages/package-crawler.js +123 -6
- package/packages/packages.js +104 -0
- package/publisher/publisher.js +290 -12
- package/registry/crawler.js +85 -6
- package/registry/registry.js +24 -7
- package/server.js +11 -2
- package/stats.js +26 -18
- package/translations/Messages.properties +2 -1
- package/tx/cs/cs-cs.js +8 -0
- package/tx/cs/cs-loinc.js +1 -0
- package/tx/cs/cs-provider-list.js +2 -1
- package/tx/cs/cs-snomed.js +142 -59
- package/tx/data/snomed-testing.cache +0 -0
- package/tx/html/home-metrics.liquid +10 -10
- package/tx/library/canonical-resource.js +4 -2
- package/tx/library/codesystem.js +31 -0
- package/tx/library/conceptmap.js +24 -0
- package/tx/library/designations.js +27 -20
- package/tx/library/renderer.js +303 -22
- package/tx/library/ucum-types.js +4 -1
- package/tx/library/valueset.js +46 -0
- package/tx/library.js +65 -21
- package/tx/operation-context.js +122 -27
- package/tx/params.js +36 -8
- package/tx/provider.js +6 -3
- package/tx/tx-html.js +34 -0
- package/tx/tx.js +92 -30
- package/tx/vs/vs-vsac.js +157 -9
- package/tx/workers/cache-control.js +186 -0
- package/tx/workers/{related.js → compare.js} +27 -27
- package/tx/workers/expand.js +100 -96
- package/tx/workers/lookup.js +6 -0
- package/tx/workers/metadata.js +11 -6
- package/tx/workers/read.js +1 -1
- package/tx/workers/translate.js +20 -29
- package/tx/workers/validate.js +18 -10
- package/tx/workers/worker.js +134 -47
- package/tx/xversion/xv-bundle.js +1 -2
- package/tx/xversion/xv-codesystem.js +5 -2
- package/tx/xversion/xv-parameters.js +4 -4
- package/tx/xversion/xv-resource.js +2 -2
- package/tx/xversion/xv-terminologyCapabilities.js +11 -6
- package/tx/xversion/xv-valueset.js +7 -7
- package/publisher/task-draft.js +0 -463
- package/tx/data/OperationDefinition-ValueSet-related.json +0 -133
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
## [v0.10.0] - 2026-06-27
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Publisher: track the IG Publisher version used for each task, and show it on the tasks list and task detail pages
|
|
14
|
+
- Packages: new `POST /packages/update-package` endpoint to force-refresh specific packages in the registry, bypassing the feed crawler's de-duplication (used to push out a corrected package after a bad publish)
|
|
15
|
+
- Publisher: automatically update SUSHI to the latest release before each draft build and publication run, installed into a FHIRsmith-owned prefix so it needs no root privileges
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Rebuilt how caching works (see the tools IG for documentation)
|
|
20
|
+
- Renamed the compare worker/operation
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Publisher: the publication run now verifies that the package about to be published is a real publication build (not a draft) before committing it to the web tree, so a failed publication build can no longer silently ship a `notForPublication` draft package
|
|
25
|
+
- Packages: the crawler now rejects any package flagged `notForPublication` at ingest, instead of only checking the feed entry
|
|
26
|
+
- Registry: better handling of versions on manual queries against the external registry
|
|
27
|
+
- Fixed handling of the cache-control header
|
|
28
|
+
- Corrections to generated test cases
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
|
|
32
|
+
- SSRF protection: outbound fetches in the packages and registry crawlers now reject any host that resolves to a non-public address (private, loopback, link-local including cloud-metadata, CGNAT, unique-local, etc.). Enforced at connection time, so it also covers redirect targets and DNS rebinding, and prevents leaking registry API keys to a redirected host
|
|
33
|
+
- Path-injection hardening: local-file feed reads in the packages crawler are confined to explicitly-allowed directories; the `update-package` endpoint only accepts http(s) URLs
|
|
34
|
+
- Dependency updates (npm audit)
|
|
35
|
+
|
|
36
|
+
### Tx Conformance Statement
|
|
37
|
+
|
|
38
|
+
FHIRsmith passed all 2503 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.11
|
|
39
|
+
|
|
40
|
+
## [v0.9.7] - 2026-06-12
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Support for tx modele to load resources directly
|
|
45
|
+
- Missing overwork protection processing ECL
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Fix bug validating secondary displays across languages
|
|
50
|
+
- Fix CORS issue (double headers)
|
|
51
|
+
- Fix czech code in snomed import
|
|
52
|
+
- Many minor validation fixes
|
|
53
|
+
- ECL processing bugs
|
|
54
|
+
- Publishing: better logging, and fix timeout & restart error
|
|
55
|
+
|
|
56
|
+
### Tx Conformance Statement
|
|
57
|
+
|
|
58
|
+
FHIRsmith passed all 2503 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.10)
|
|
59
|
+
|
|
9
60
|
## [v0.9.6] - 2026-05-21
|
|
10
61
|
|
|
11
62
|
### Added
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { PackageContentLoader } = require('./package-manager');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A content loader that scans a folder for JSON FHIR terminology resources
|
|
7
|
+
* (CodeSystem, ValueSet, ConceptMap). It synthesizes the package layout that
|
|
8
|
+
* PackageContentLoader expects so the existing PackageValueSetProvider /
|
|
9
|
+
* PackageConceptMapProvider machinery can be reused without modification.
|
|
10
|
+
*
|
|
11
|
+
* The user's source folder is treated as read-only. Any SQLite caches built
|
|
12
|
+
* by the Package*Provider classes are written to the cacheFolder passed in,
|
|
13
|
+
* not to the source folder.
|
|
14
|
+
*/
|
|
15
|
+
class FolderContentLoader extends PackageContentLoader {
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} sourceFolder - Folder to scan for JSON resources
|
|
18
|
+
* @param {string} cacheFolder - Folder where Package*Provider DBs may live
|
|
19
|
+
*/
|
|
20
|
+
constructor(sourceFolder, cacheFolder) {
|
|
21
|
+
super(cacheFolder);
|
|
22
|
+
this.sourceFolder = sourceFolder;
|
|
23
|
+
// Resources are read directly from the source folder rather than from
|
|
24
|
+
// <packageFolder>/package as PackageContentLoader assumes.
|
|
25
|
+
this.packageSubfolder = sourceFolder;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Replaces the standard initialize: there is no package.json or .index.json
|
|
30
|
+
* to read, so we synthesize an index from the JSON files in the folder.
|
|
31
|
+
*/
|
|
32
|
+
async initialize() {
|
|
33
|
+
if (this.loaded) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const stat = await fs.stat(this.sourceFolder);
|
|
39
|
+
if (!stat.isDirectory()) {
|
|
40
|
+
throw new Error(`Folder source path is not a directory: ${this.sourceFolder}`);
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code === 'ENOENT') {
|
|
44
|
+
throw new Error(`Folder source path does not exist: ${this.sourceFolder}`);
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Synthesize package metadata so id()/version()/pid() return something usable.
|
|
50
|
+
this.package = {
|
|
51
|
+
name: 'folder.' + path.basename(this.sourceFolder),
|
|
52
|
+
version: '0.0.0',
|
|
53
|
+
fhirVersions: ['5.0.0']
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const files = [];
|
|
57
|
+
const entries = await fs.readdir(this.sourceFolder, { withFileTypes: true });
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (!entry.isFile()) continue;
|
|
60
|
+
if (!entry.name.toLowerCase().endsWith('.json')) continue;
|
|
61
|
+
|
|
62
|
+
const fullPath = path.join(this.sourceFolder, entry.name);
|
|
63
|
+
let json;
|
|
64
|
+
try {
|
|
65
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
|
66
|
+
json = JSON.parse(content);
|
|
67
|
+
} catch {
|
|
68
|
+
// Skip unreadable / non-JSON files rather than failing the whole load.
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!json || typeof json !== 'object') continue;
|
|
73
|
+
const rt = json.resourceType;
|
|
74
|
+
if (rt !== 'CodeSystem' && rt !== 'ValueSet' && rt !== 'ConceptMap') continue;
|
|
75
|
+
|
|
76
|
+
files.push({
|
|
77
|
+
filename: entry.name,
|
|
78
|
+
resourceType: rt,
|
|
79
|
+
id: json.id,
|
|
80
|
+
url: json.url,
|
|
81
|
+
version: json.version
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.index = { files, 'index-version': 2 };
|
|
86
|
+
this.buildIndexes();
|
|
87
|
+
this.loaded = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { FolderContentLoader };
|
|
@@ -127,12 +127,8 @@ class NpmProjectorModule {
|
|
|
127
127
|
* Set up Express routes
|
|
128
128
|
*/
|
|
129
129
|
setupRoutes() {
|
|
130
|
-
// CORS
|
|
131
|
-
|
|
132
|
-
res.header('Access-Control-Allow-Origin', '*');
|
|
133
|
-
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
|
|
134
|
-
next();
|
|
135
|
-
});
|
|
130
|
+
// CORS is handled once at the app level (server.js, from config.server.cors).
|
|
131
|
+
// Do not set Access-Control-* headers here.
|
|
136
132
|
|
|
137
133
|
// Root - module info
|
|
138
134
|
this.router.get('/', (req, res) => {
|
package/package.json
CHANGED
|
@@ -9,8 +9,27 @@ const {XMLParser} = require('fast-xml-parser');
|
|
|
9
9
|
const crypto = require('crypto');
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
|
+
const dns = require('dns');
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const https = require('https');
|
|
15
|
+
const ipaddr = require('ipaddr.js');
|
|
12
16
|
const {debugLog} = require("../tx/operation-context");
|
|
13
17
|
|
|
18
|
+
// True if an IP literal is anything other than a normal public (unicast) address:
|
|
19
|
+
// loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
|
|
20
|
+
// CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
|
|
21
|
+
function isNonPublicAddress(ip) {
|
|
22
|
+
try {
|
|
23
|
+
let addr = ipaddr.parse(ip);
|
|
24
|
+
if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
|
|
25
|
+
addr = addr.toIPv4Address();
|
|
26
|
+
}
|
|
27
|
+
return addr.range() !== 'unicast';
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return true; // unparseable - treat as unsafe
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
class PackageCrawler {
|
|
15
34
|
log;
|
|
16
35
|
packages = new Set();
|
|
@@ -139,10 +158,96 @@ class PackageCrawler {
|
|
|
139
158
|
return url.replace(/^http:/, 'https:');
|
|
140
159
|
}
|
|
141
160
|
|
|
161
|
+
// Roots under which local-file feed reads are permitted. Reading local files supports
|
|
162
|
+
// locally-configured feeds (e.g. for testing); allowed roots come from
|
|
163
|
+
// config.localFeedDirs, plus the directory of a local master feed url. Anything else
|
|
164
|
+
// is rejected so a third-party feed can't point a read at an arbitrary server file.
|
|
165
|
+
allowedLocalRoots() {
|
|
166
|
+
const roots = [];
|
|
167
|
+
const cfg = this.config && this.config.localFeedDirs;
|
|
168
|
+
if (Array.isArray(cfg)) {
|
|
169
|
+
roots.push(...cfg);
|
|
170
|
+
} else if (typeof cfg === 'string' && cfg.length > 0) {
|
|
171
|
+
roots.push(cfg);
|
|
172
|
+
}
|
|
173
|
+
if (this.config && typeof this.config.masterUrl === 'string' && this.config.masterUrl.startsWith('/')) {
|
|
174
|
+
roots.push(path.dirname(this.config.masterUrl));
|
|
175
|
+
}
|
|
176
|
+
return roots.map((r) => path.resolve(r));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A DNS lookup wrapper that rejects any host resolving to a non-public address.
|
|
180
|
+
// Enforced at connection time (so it also covers redirect targets and defeats
|
|
181
|
+
// DNS-rebinding), this is the SSRF guard for all outbound http(s) fetches.
|
|
182
|
+
// Set config.allowPrivateAddresses = true to disable (e.g. for local test registries).
|
|
183
|
+
ssrfLookup() {
|
|
184
|
+
const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
|
|
185
|
+
return (hostname, options, callback) => {
|
|
186
|
+
if (typeof options === 'function') {
|
|
187
|
+
callback = options;
|
|
188
|
+
options = {};
|
|
189
|
+
}
|
|
190
|
+
const wantAll = !!(options && options.all);
|
|
191
|
+
dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
|
|
192
|
+
if (err) {
|
|
193
|
+
callback(err);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!allowPrivate) {
|
|
197
|
+
for (const a of addresses) {
|
|
198
|
+
if (isNonPublicAddress(a.address)) {
|
|
199
|
+
callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (wantAll) {
|
|
205
|
+
callback(null, addresses);
|
|
206
|
+
} else {
|
|
207
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// http/https agents that route every connection through the SSRF lookup. Cached so
|
|
214
|
+
// connections can be pooled across requests.
|
|
215
|
+
guardedAgents() {
|
|
216
|
+
if (!this._guardedAgents) {
|
|
217
|
+
const lookup = this.ssrfLookup();
|
|
218
|
+
class GuardedHttpAgent extends http.Agent {
|
|
219
|
+
createConnection(options, cb) {
|
|
220
|
+
return super.createConnection(Object.assign({}, options, { lookup }), cb);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
class GuardedHttpsAgent extends https.Agent {
|
|
224
|
+
createConnection(options, cb) {
|
|
225
|
+
return super.createConnection(Object.assign({}, options, { lookup }), cb);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this._guardedAgents = {
|
|
229
|
+
httpAgent: new GuardedHttpAgent({ keepAlive: true }),
|
|
230
|
+
httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return this._guardedAgents;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Resolve a local feed path and confine it to an allowed root (path-injection guard).
|
|
237
|
+
resolveLocalReadPath(url) {
|
|
238
|
+
const resolved = path.resolve(url);
|
|
239
|
+
const roots = this.allowedLocalRoots();
|
|
240
|
+
const allowed = roots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
|
|
241
|
+
if (!allowed) {
|
|
242
|
+
throw new Error('Refusing to read local file outside the allowed feed directories: ' + url);
|
|
243
|
+
}
|
|
244
|
+
return resolved;
|
|
245
|
+
}
|
|
246
|
+
|
|
142
247
|
async fetchJson(url) {
|
|
143
248
|
try {
|
|
144
249
|
if (url.startsWith("/")) {
|
|
145
|
-
const content = await fs.promises.readFile(url, "utf8");
|
|
250
|
+
const content = await fs.promises.readFile(this.resolveLocalReadPath(url), "utf8");
|
|
146
251
|
return JSON.parse(content);
|
|
147
252
|
} else {
|
|
148
253
|
const response = await axios.get(url, {
|
|
@@ -150,7 +255,9 @@ class PackageCrawler {
|
|
|
150
255
|
signal: this.abortController?.signal,
|
|
151
256
|
headers: {
|
|
152
257
|
'User-Agent': 'FHIR Package Crawler/1.0'
|
|
153
|
-
}
|
|
258
|
+
},
|
|
259
|
+
httpAgent: this.guardedAgents().httpAgent,
|
|
260
|
+
httpsAgent: this.guardedAgents().httpsAgent
|
|
154
261
|
});
|
|
155
262
|
return response.data;
|
|
156
263
|
}
|
|
@@ -166,7 +273,7 @@ class PackageCrawler {
|
|
|
166
273
|
async fetchXml(url) {
|
|
167
274
|
try {
|
|
168
275
|
if (url.startsWith("/")) {
|
|
169
|
-
const content = await fs.promises.readFile(url, 'utf8');
|
|
276
|
+
const content = await fs.promises.readFile(this.resolveLocalReadPath(url), 'utf8');
|
|
170
277
|
const parser = new XMLParser({
|
|
171
278
|
ignoreAttributes: false,
|
|
172
279
|
attributeNamePrefix: '@_',
|
|
@@ -180,7 +287,9 @@ class PackageCrawler {
|
|
|
180
287
|
signal: this.abortController?.signal,
|
|
181
288
|
headers: {
|
|
182
289
|
'User-Agent': 'FHIR Package Crawler/1.0'
|
|
183
|
-
}
|
|
290
|
+
},
|
|
291
|
+
httpAgent: this.guardedAgents().httpAgent,
|
|
292
|
+
httpsAgent: this.guardedAgents().httpsAgent
|
|
184
293
|
});
|
|
185
294
|
|
|
186
295
|
const parser = new XMLParser({
|
|
@@ -203,7 +312,7 @@ class PackageCrawler {
|
|
|
203
312
|
async fetchUrl(url) {
|
|
204
313
|
try {
|
|
205
314
|
if (url.startsWith("/")) {
|
|
206
|
-
const buffer = await fs.promises.readFile(url);
|
|
315
|
+
const buffer = await fs.promises.readFile(this.resolveLocalReadPath(url));
|
|
207
316
|
this.totalBytes += buffer.byteLength;
|
|
208
317
|
return buffer;
|
|
209
318
|
} else {
|
|
@@ -213,7 +322,9 @@ class PackageCrawler {
|
|
|
213
322
|
signal: this.abortController?.signal,
|
|
214
323
|
headers: {
|
|
215
324
|
'User-Agent': 'FHIR Package Crawler/1.0'
|
|
216
|
-
}
|
|
325
|
+
},
|
|
326
|
+
httpAgent: this.guardedAgents().httpAgent,
|
|
327
|
+
httpsAgent: this.guardedAgents().httpsAgent
|
|
217
328
|
});
|
|
218
329
|
|
|
219
330
|
this.totalBytes += response.data.byteLength;
|
|
@@ -558,6 +669,12 @@ class PackageCrawler {
|
|
|
558
669
|
if (npmPackage.hasJavaScript && !isTemplate && id !== 'hl7.fhir.pubpack') {
|
|
559
670
|
throw new Error(`Package ${idver} rejected: contains JavaScript files but is not a template package`);
|
|
560
671
|
}
|
|
672
|
+
// The feed gate (item.notForPublication) only sees the RSS entry. A package whose
|
|
673
|
+
// feed entry is clean can still carry notForPublication inside the tarball - that is
|
|
674
|
+
// a draft build that must never enter the registry. Reject it here too.
|
|
675
|
+
if (npmPackage.notForPublication) {
|
|
676
|
+
throw new Error(`Package ${idver} rejected: tarball is flagged notForPublication (draft build, not suitable for publication)`);
|
|
677
|
+
}
|
|
561
678
|
|
|
562
679
|
// Extract URLs from package
|
|
563
680
|
const urls = this.processPackageUrls(npmPackage);
|
package/packages/packages.js
CHANGED
|
@@ -592,6 +592,64 @@ class PackagesModule {
|
|
|
592
592
|
}
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
+
// Re-fetch a single package tarball and replace whatever is stored for it, bypassing
|
|
596
|
+
// the crawler's GUID dedup and notForPublication feed gate. Used to push out a
|
|
597
|
+
// corrected package that was already (mis)published.
|
|
598
|
+
async forceUpdatePackage(link) {
|
|
599
|
+
// Only allow fetching over http(s). This endpoint must never be usable to read
|
|
600
|
+
// local server files (path injection) - tarballs are always published web URLs.
|
|
601
|
+
if (!link || !/^https?:\/\//i.test(link)) {
|
|
602
|
+
throw new Error('Invalid package link (must be an http(s) URL): ' + link);
|
|
603
|
+
}
|
|
604
|
+
if (!this.crawler) {
|
|
605
|
+
this.crawler = new PackageCrawler(this.config, this.db, this.stats);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// The feed uses the versioned package.tgz url as the (permalink) GUID, so reusing
|
|
609
|
+
// the link as the GUID keeps a later crawl from inserting a duplicate.
|
|
610
|
+
const guid = link;
|
|
611
|
+
|
|
612
|
+
const buffer = await this.crawler.fetchUrl(link);
|
|
613
|
+
const npm = await this.crawler.extractNpmPackage(buffer, link);
|
|
614
|
+
|
|
615
|
+
// Refuse to re-store a still-broken package - that would just re-publish the bug.
|
|
616
|
+
if (npm.notForPublication) {
|
|
617
|
+
throw new Error('Refusing to store ' + npm.id + '#' + npm.version + ': fetched tarball is still flagged notForPublication');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const idver = npm.id + '#' + npm.version;
|
|
621
|
+
const replaced = await this.deleteVersionsByGuid(guid);
|
|
622
|
+
|
|
623
|
+
const itemLog = { status: '??' };
|
|
624
|
+
await this.crawler.store(link, link, guid, new Date(), buffer, idver, itemLog);
|
|
625
|
+
|
|
626
|
+
pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' + replaced + ' existing row(s))');
|
|
627
|
+
return { status: 'updated', id: npm.id, version: npm.version, replaced };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Delete a stored package version and all of its child rows, by GUID.
|
|
631
|
+
deleteVersionsByGuid(guid) {
|
|
632
|
+
return new Promise((resolve, reject) => {
|
|
633
|
+
this.db.all('SELECT PackageVersionKey FROM PackageVersions WHERE GUID = ?', [guid], (err, rows) => {
|
|
634
|
+
if (err) return reject(err);
|
|
635
|
+
const keys = (rows || []).map(r => r.PackageVersionKey);
|
|
636
|
+
if (keys.length === 0) return resolve(0);
|
|
637
|
+
const ph = keys.map(() => '?').join(',');
|
|
638
|
+
const stmts = [
|
|
639
|
+
'DELETE FROM PackageFHIRVersions WHERE PackageVersionKey IN (' + ph + ')',
|
|
640
|
+
'DELETE FROM PackageDependencies WHERE PackageVersionKey IN (' + ph + ')',
|
|
641
|
+
'DELETE FROM PackageURLs WHERE PackageVersionKey IN (' + ph + ')',
|
|
642
|
+
'DELETE FROM PackageVersions WHERE PackageVersionKey IN (' + ph + ')'
|
|
643
|
+
];
|
|
644
|
+
const runNext = (i) => {
|
|
645
|
+
if (i >= stmts.length) return resolve(keys.length);
|
|
646
|
+
this.db.run(stmts[i], keys, (e) => e ? reject(e) : runNext(i + 1));
|
|
647
|
+
};
|
|
648
|
+
runNext(0);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
595
653
|
async initializeDatabase() {
|
|
596
654
|
return new Promise((resolve, reject) => {
|
|
597
655
|
// Use config path if absolute, otherwise resolve relative to data dir
|
|
@@ -1223,6 +1281,52 @@ class PackagesModule {
|
|
|
1223
1281
|
}
|
|
1224
1282
|
});
|
|
1225
1283
|
|
|
1284
|
+
// Force-refresh specific packages, bypassing the feed. The crawler only fetches a
|
|
1285
|
+
// package once (it dedupes on the feed GUID) and skips anything flagged
|
|
1286
|
+
// notForPublication, so there is normally no way to make it re-pick-up a package
|
|
1287
|
+
// that was published incorrectly and later corrected. This endpoint re-fetches the
|
|
1288
|
+
// tarball(s) directly and replaces whatever is stored.
|
|
1289
|
+
//
|
|
1290
|
+
// POST /update-package { "links": ["http://hl7.org/fhir/uv/ips/2.0.1/package.tgz", ...] }
|
|
1291
|
+
//
|
|
1292
|
+
// The link is the versioned package.tgz url, which is exactly the GUID the feed uses,
|
|
1293
|
+
// so the replacement keeps the same GUID and a later crawl won't create a duplicate.
|
|
1294
|
+
// If config.updateToken is set, the request must carry it in the x-update-token header.
|
|
1295
|
+
this.router.post('/update-package', async (req, res) => {
|
|
1296
|
+
const start = Date.now();
|
|
1297
|
+
try {
|
|
1298
|
+
if (this.config.updateToken && req.headers['x-update-token'] !== this.config.updateToken) {
|
|
1299
|
+
res.status(403).json({ error: 'forbidden: missing or invalid x-update-token' });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
let links = req.body && (req.body.links || req.body.packages || (req.body.url ? [req.body.url] : (req.body.link ? [req.body.link] : null)));
|
|
1303
|
+
if (typeof links === 'string') links = [links];
|
|
1304
|
+
if (!Array.isArray(links) || links.length === 0) {
|
|
1305
|
+
res.status(400).json({ error: 'Provide a JSON body like {"links": ["<package.tgz url>", ...]}' });
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
const results = [];
|
|
1309
|
+
for (const link of links) {
|
|
1310
|
+
try {
|
|
1311
|
+
results.push(Object.assign({ link }, await this.forceUpdatePackage(link)));
|
|
1312
|
+
} catch (e) {
|
|
1313
|
+
pckLog.error('Force update failed for ' + link + ': ' + e.message);
|
|
1314
|
+
results.push({ link, status: 'error', error: e.message });
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const failed = results.filter(r => r.status === 'error').length;
|
|
1318
|
+
res.status(failed === results.length ? 500 : 200).json({
|
|
1319
|
+
message: 'Processed ' + results.length + ' package(s), ' + failed + ' failed',
|
|
1320
|
+
results
|
|
1321
|
+
});
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
pckLog.error('update-package endpoint failed:', error);
|
|
1324
|
+
res.status(500).json({ error: 'update-package failed', message: error.message });
|
|
1325
|
+
} finally {
|
|
1326
|
+
this.stats.countRequest('update-package', Date.now() - start);
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1226
1330
|
// Crawler statistics endpoint (existing)
|
|
1227
1331
|
this.router.get('/stats', async (req, res) => {
|
|
1228
1332
|
const start = Date.now();
|