fhirsmith 0.18.1 → 1.0.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/ACCESSIBILITY.md +119 -0
- package/CHANGELOG.md +190 -9
- package/CONFIGURATION.md +305 -0
- package/LICENSE +29 -0
- package/README.md +29 -122
- package/cluster.js +1220 -0
- package/cluster.md +707 -0
- package/config-template.json +77 -8
- package/extension-tracker/extension-tracker-template.html +5 -8
- package/extension-tracker/extension-tracker.js +0 -2
- package/folder/folder.js +7 -6
- package/ips/analysis.js +560 -0
- package/ips/ips-template.html +5 -8
- package/ips/ips.js +331 -33
- package/ips/jurisdictions.js +65 -0
- package/ips/nationality.js +120 -0
- package/ips/pipeline.js +131 -17
- package/ips/presenter.js +1 -1
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +170 -20
- package/ips/render.js +769 -109
- package/ips/scripts.js +46 -0
- package/ips/store.js +123 -11
- package/ips/structure.js +247 -0
- package/ips/terminology.js +1 -1
- package/library/body-limit.js +22 -0
- package/library/cache-generation.js +15 -0
- package/library/cgroup-memory.js +133 -1
- package/library/cluster-counts.js +121 -0
- package/library/cluster-events.js +153 -0
- package/library/cluster-metrics.js +193 -0
- package/library/cluster-status.js +468 -0
- package/library/config-reader.js +96 -0
- package/library/daily-report.js +100 -2
- package/library/html-server.js +27 -1
- package/library/i18nsupport.js +23 -1
- package/library/keep-alive.js +18 -0
- package/library/languages.js +17 -3
- package/library/logger.js +31 -0
- package/library/memory-guard.js +428 -0
- package/library/native-memory.js +52 -0
- package/library/npm-audit.js +51 -25
- package/library/openapi-doc.js +7 -5
- package/library/package-manager.js +8 -0
- package/library/role.js +197 -0
- package/library/server-credentials.js +169 -0
- package/library/sqlite-utilities.js +59 -0
- package/library/ssrf-guard.js +100 -0
- package/library/static-response-stats.js +16 -0
- package/library/version-utilities.js +50 -0
- package/library/worker-count.js +192 -0
- package/nginx.md +30 -11
- package/npmprojector/indexer.js +18 -10
- package/package.json +11 -8
- package/packages/feed-check-page.js +117 -0
- package/packages/feed-check.js +279 -0
- package/packages/npm-check.js +57 -3
- package/packages/npm-signing.js +219 -0
- package/packages/openapi.yaml +207 -1
- package/packages/package-crawler.js +322 -41
- package/packages/packages-template.html +6 -8
- package/packages/packages.js +604 -57
- package/packages/readme.md +113 -1
- package/publisher/publisher-template.html +2 -5
- package/publisher/publisher.js +121 -110
- package/publisher/readme.md +75 -0
- package/registry/api.js +179 -1
- package/registry/crawler.js +86 -108
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/configuration.md +34 -0
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry-template.html +5 -8
- package/registry/registry.js +58 -6
- package/root-bare-template.html +7 -4
- package/root-template.html +5 -8
- package/server.js +398 -198
- package/shl/shl.js +38 -37
- package/static/a11y/a11y-check.js +832 -0
- package/static/a11y/axe.min.js +12 -0
- package/static/assets/css/bootstrap-fhir.css +15 -15
- package/static/assets/images/flags/au.svg +18 -0
- package/static/assets/images/flags/unknown.svg +11 -0
- package/static/assets/images/flags/us.svg +63 -0
- package/static/assets/images/icon_audit.png +0 -0
- package/static/assets/images/icon_clinical.png +0 -0
- package/static/assets/images/icon_document.png +0 -0
- package/static/assets/images/icon_external.png +0 -0
- package/static/assets/images/icon_medication.png +0 -0
- package/static/assets/images/icon_organization.png +0 -0
- package/static/assets/images/icon_other.png +0 -0
- package/static/assets/images/icon_person.png +0 -0
- package/static/assets/images/icon_section.png +0 -0
- package/stats-db.js +45 -0
- package/stats.js +197 -10
- package/testing/testing-template.html +5 -8
- package/token/nginx_passport_setup.md +1 -1
- package/token/security_guide.md +1 -1
- package/token/token-template.html +2 -5
- package/token/token.js +40 -36
- package/translations/Messages.properties +19 -0
- package/tx/README.md +158 -4
- package/tx/closure/closure-store.js +126 -3
- package/tx/cm/cm-database.js +69 -57
- package/tx/cm/cm-package.js +50 -16
- package/tx/cs/cs-api.js +284 -19
- package/tx/cs/cs-api.md +41 -4
- package/tx/cs/cs-areacode.js +1 -1
- package/tx/cs/cs-country.js +17 -5
- package/tx/cs/cs-cpt.js +33 -16
- package/tx/cs/cs-cs.js +199 -24
- package/tx/cs/cs-currency.js +1 -1
- package/tx/cs/cs-icd11.js +21 -13
- package/tx/cs/cs-icf.js +22 -3
- package/tx/cs/cs-lang.js +2 -2
- package/tx/cs/cs-loinc.js +193 -67
- package/tx/cs/cs-mimetypes.js +12 -2
- package/tx/cs/cs-ndc.js +40 -25
- package/tx/cs/cs-omop.js +54 -29
- package/tx/cs/cs-rxnorm.js +300 -196
- package/tx/cs/cs-snomed.js +861 -95
- package/tx/cs/cs-ucum.js +14 -4
- package/tx/cs/cs-unii.js +18 -14
- package/tx/cs/cs-virtual.js +210 -0
- package/tx/ecosystem-resolver.js +413 -0
- package/tx/html/cluster-dash-metrics.liquid +26 -0
- package/tx/html/cluster-home-metrics.liquid +31 -0
- package/tx/html/cluster-metrics-script.liquid +251 -0
- package/tx/html/codesystem-operations.liquid +3 -3
- package/tx/html/ecl-panel.liquid +1 -1
- package/tx/html/external-metrics.liquid +17 -0
- package/tx/html/operations-form.liquid +17 -17
- package/tx/html/search-form.liquid +11 -11
- package/tx/html/status-line.liquid +58 -0
- package/tx/html/status-panel.liquid +140 -0
- package/tx/html/status-tasks.liquid +25 -0
- package/tx/html/tx-template.html +2 -5
- package/tx/importers/atc-to-fhir.js +17 -16
- package/tx/importers/export-icd11-codesystem.js +3 -2
- package/tx/importers/import-icd11.js +4 -3
- package/tx/importers/import-loinc.module.js +118 -56
- package/tx/importers/import-ndc.module.js +56 -44
- package/tx/importers/import-rxnorm.module.js +118 -79
- package/tx/importers/import-sct.module.js +125 -61
- package/tx/importers/import-unii.module.js +37 -36
- package/tx/importers/subset-loinc.module.js +49 -48
- package/tx/importers/subset-rxnorm.module.js +29 -28
- package/tx/importers/terminal.js +18 -0
- package/tx/importers/tx-import-base.js +13 -12
- package/tx/importers/tx-import-settings.js +5 -4
- package/tx/importers/tx-import.js +53 -52
- package/tx/library/canonical-resource.js +49 -0
- package/tx/library/codesystem.js +94 -5
- package/tx/library/designations.js +45 -2
- package/tx/library/extensions.js +13 -7
- package/tx/library/implicit-valuesets.js +66 -0
- package/tx/library/renderer.js +77 -21
- package/tx/library/sqlite-utilities.js +2 -0
- package/tx/library/tho-precedence.js +40 -0
- package/tx/library/ucum-parsers.js +11 -1
- package/tx/library/wait-for-cache.js +43 -0
- package/tx/library.js +321 -22
- package/tx/openapi-operations.js +6 -4
- package/tx/operation-context.js +460 -99
- package/tx/params.js +25 -0
- package/tx/problems.js +3 -5
- package/tx/provider.js +314 -67
- package/tx/resource-cache-sqlite.js +1050 -0
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +81 -17
- package/tx/sct/editions.js +39 -0
- package/tx/sct/expressions.js +10 -1
- package/tx/sct/load-config.js +107 -0
- package/tx/sct/mmap.js +109 -0
- package/tx/sct/structures.js +175 -15
- package/tx/sct/text-index.js +5 -1
- package/tx/syndication/syndication-feed.js +2 -2
- package/tx/tx-html.js +44 -8
- package/tx/tx.fhir.org.yml +8 -8
- package/tx/tx.js +282 -80
- package/tx/usage-tracker.js +39 -11
- package/tx/vs/vs-api.js +19 -0
- package/tx/vs/vs-database.js +386 -62
- package/tx/vs/vs-package.js +44 -17
- package/tx/vs/vs-vsac.js +649 -101
- package/tx/workers/batch-validate.js +37 -10
- package/tx/workers/batch.js +6 -1
- package/tx/workers/cache-control.js +15 -4
- package/tx/workers/closure.js +156 -13
- package/tx/workers/compare.js +8 -5
- package/tx/workers/expand.js +1173 -289
- package/tx/workers/metadata.js +35 -0
- package/tx/workers/search.js +53 -1
- package/tx/workers/supplement-filters.js +145 -0
- package/tx/workers/translate.js +169 -4
- package/tx/workers/validate.js +223 -119
- package/tx/workers/worker.js +123 -31
- package/tx/xversion/xv-valueset.js +7 -4
- package/utilities/submit-test-report.js +3 -1
- package/utilities/tx-stress-test.js +482 -0
- package/vcl/readme.md +37 -0
- package/vcl/vcl-parser.js +15 -0
- package/xig/readme.md +52 -0
- package/xig/xig-template.html +5 -8
- package/xig/xig.js +238 -422
- package/changelog-template.md +0 -22
- package/registry/test-runner.js +0 -237
- package/release.sh +0 -80
- package/test-scripts/repro-re2-wasm-leak.js +0 -34
- package/tsconfig.eslint.json +0 -9
- package/tx/html/dash-metrics.liquid +0 -147
- package/tx/html/home-metrics.liquid +0 -225
|
@@ -17,6 +17,7 @@ const https = require('https');
|
|
|
17
17
|
const ipaddr = require('ipaddr.js');
|
|
18
18
|
const {debugLog} = require("../tx/operation-context");
|
|
19
19
|
const {Parser: TarParser} = require('tar');
|
|
20
|
+
const {integrityOfSync, verifyPackageSignature} = require('./npm-signing');
|
|
20
21
|
|
|
21
22
|
const MB = 1024 * 1024;
|
|
22
23
|
|
|
@@ -72,6 +73,16 @@ function isLoopbackAddress(ip) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
// A mirror file is written as <file>.tgz.tmp-<pid> and then renamed into place
|
|
77
|
+
const MIRROR_TMP_MARKER = '.tmp-';
|
|
78
|
+
|
|
79
|
+
// crawler.signatures: what to do with publishers' package signatures (see signingFor)
|
|
80
|
+
const SIGNATURE_MODES = ['ignore', 'check', 'require'];
|
|
81
|
+
// a signature file is a few KB; anything much bigger isn't one
|
|
82
|
+
const MAX_SIGNATURE_BYTES = 1024 * 1024;
|
|
83
|
+
// A temporary mirror file older than this was left by a crawler that stopped mid-write
|
|
84
|
+
const MIRROR_TMP_MAX_AGE_MS = 60 * 60 * 1000;
|
|
85
|
+
|
|
75
86
|
class PackageCrawler {
|
|
76
87
|
log;
|
|
77
88
|
packages = new Set();
|
|
@@ -84,6 +95,15 @@ class PackageCrawler {
|
|
|
84
95
|
this.crawlerLog = {};
|
|
85
96
|
this.errors = '';
|
|
86
97
|
this.abortController = null;
|
|
98
|
+
// the npm signing key (NpmSigner), or null when this server doesn't sign
|
|
99
|
+
this.signer = null;
|
|
100
|
+
this.skippedFeeds = new Set();
|
|
101
|
+
// publisher signatures (fhir:signature in a feed, keys in package-feeds.json): ignore,
|
|
102
|
+
// check, or require - see signingFor()
|
|
103
|
+
this.signatureMode = (config.crawler && config.crawler.signatures) || 'check';
|
|
104
|
+
if (!SIGNATURE_MODES.includes(this.signatureMode)) {
|
|
105
|
+
throw new Error(`crawler.signatures must be one of ${SIGNATURE_MODES.join(', ')}, not ${this.signatureMode}`);
|
|
106
|
+
}
|
|
87
107
|
this.db.run('PRAGMA journal_mode = WAL');
|
|
88
108
|
this.db.run('PRAGMA busy_timeout = 5000');
|
|
89
109
|
}
|
|
@@ -110,6 +130,7 @@ class PackageCrawler {
|
|
|
110
130
|
async crawl(log) {
|
|
111
131
|
this.log = log;
|
|
112
132
|
this.packages.clear();
|
|
133
|
+
this.skippedFeeds = new Set();
|
|
113
134
|
this.abortController = new AbortController();
|
|
114
135
|
|
|
115
136
|
const startTime = Date.now();
|
|
@@ -122,6 +143,11 @@ class PackageCrawler {
|
|
|
122
143
|
};
|
|
123
144
|
|
|
124
145
|
this.log.info('Running web crawler for packages using master URL: '+ this.config.masterUrl);
|
|
146
|
+
try {
|
|
147
|
+
this.cleanMirrorTempFiles();
|
|
148
|
+
} catch (e) {
|
|
149
|
+
this.log.warn(`Could not clean the package mirror: ${e.message}`);
|
|
150
|
+
}
|
|
125
151
|
this.stats.task('Package Crawler', 'Running');
|
|
126
152
|
|
|
127
153
|
try {
|
|
@@ -144,9 +170,12 @@ class PackageCrawler {
|
|
|
144
170
|
}
|
|
145
171
|
try {
|
|
146
172
|
let url = this.fixUrl(feedConfig.url)
|
|
173
|
+
if (this.skipUnsigned(feedConfig)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
147
176
|
if (!url.includes('simplifier')) {
|
|
148
177
|
this.stats.task('Package Crawler', 'Running for '+feedConfig.url);
|
|
149
|
-
await this.updateTheFeed(url, this.config.masterUrl,feedConfig.errors ? feedConfig.errors.replace(/\|/g, '@').replace(/_/g, '.') : '', packageRestrictions);
|
|
178
|
+
await this.updateTheFeed(url, this.config.masterUrl,feedConfig.errors ? feedConfig.errors.replace(/\|/g, '@').replace(/_/g, '.') : '', packageRestrictions, this.signingFor(feedConfig.signing));
|
|
150
179
|
}
|
|
151
180
|
} catch (feedError) {
|
|
152
181
|
this.log.error(`Failed to process feed ${feedConfig.url}: `+ feedError.message);
|
|
@@ -163,9 +192,12 @@ class PackageCrawler {
|
|
|
163
192
|
|
|
164
193
|
try {
|
|
165
194
|
let url = this.fixUrl(feedConfig.url)
|
|
195
|
+
if (this.skipUnsigned(feedConfig)) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
166
198
|
if (url.includes('simplifier')) {
|
|
167
199
|
this.stats.task('Package Crawler', 'Running for '+feedConfig.url);
|
|
168
|
-
await this.updateTheFeed(url, this.config.masterUrl,feedConfig.errors ? feedConfig.errors.replace(/\|/g, '@').replace(/_/g, '.') : '', packageRestrictions);
|
|
200
|
+
await this.updateTheFeed(url, this.config.masterUrl,feedConfig.errors ? feedConfig.errors.replace(/\|/g, '@').replace(/_/g, '.') : '', packageRestrictions, this.signingFor(feedConfig.signing));
|
|
169
201
|
}
|
|
170
202
|
} catch (feedError) {
|
|
171
203
|
this.log.error(`Failed to process feed ${feedConfig.url}: `+ feedError.message);
|
|
@@ -466,7 +498,7 @@ class PackageCrawler {
|
|
|
466
498
|
return body;
|
|
467
499
|
}
|
|
468
500
|
|
|
469
|
-
async updateTheFeed(url, source, email, packageRestrictions) {
|
|
501
|
+
async updateTheFeed(url, source, email, packageRestrictions, signing = null) {
|
|
470
502
|
const feedLog = {
|
|
471
503
|
url: url,
|
|
472
504
|
items: []
|
|
@@ -522,7 +554,7 @@ class PackageCrawler {
|
|
|
522
554
|
for (let i = 0; i < items.length; i++) {
|
|
523
555
|
if (this.abortController?.signal.aborted) break;
|
|
524
556
|
try {
|
|
525
|
-
await this.updateItem(currentUrl, items[i], i, packageRestrictions, feedLog);
|
|
557
|
+
await this.updateItem(currentUrl, items[i], i, packageRestrictions, feedLog, signing);
|
|
526
558
|
} catch (itemError) {
|
|
527
559
|
if (itemError.message.includes('RATE_LIMITED')) {
|
|
528
560
|
this.log.info(`Rate limited while downloading package from ${currentUrl}, stopping feed processing`);
|
|
@@ -568,7 +600,8 @@ class PackageCrawler {
|
|
|
568
600
|
}
|
|
569
601
|
}
|
|
570
602
|
|
|
571
|
-
|
|
603
|
+
// signing: how this feed's signatures are checked (signingFor), or null
|
|
604
|
+
async updateItem(source, item, index, packageRestrictions, feedLog, signing = null) {
|
|
572
605
|
const itemLog = {
|
|
573
606
|
status: '??'
|
|
574
607
|
};
|
|
@@ -628,6 +661,7 @@ class PackageCrawler {
|
|
|
628
661
|
// Check if already processed
|
|
629
662
|
if (await this.hasStored(guid)) {
|
|
630
663
|
itemLog.status = 'Already Processed';
|
|
664
|
+
await this.checkStoredSignature(item, guid, signing, itemLog);
|
|
631
665
|
return;
|
|
632
666
|
}
|
|
633
667
|
|
|
@@ -672,7 +706,8 @@ class PackageCrawler {
|
|
|
672
706
|
this.log.info('Fetching package: ' + url);
|
|
673
707
|
|
|
674
708
|
const packageContent = await this.fetchUrl(url, 'application/tar+gzip');
|
|
675
|
-
const
|
|
709
|
+
const signature = signing ? await this.fetchSignature(item) : null;
|
|
710
|
+
const outcome = await this.store(source, url, guid, pubDate, packageContent, id, itemLog, signing, signature);
|
|
676
711
|
|
|
677
712
|
itemLog.status = {stored: 'Fetched', replaced: 'Replaced Simplifier copy', skipped: 'Already Stored'}[outcome];
|
|
678
713
|
|
|
@@ -735,6 +770,141 @@ class PackageCrawler {
|
|
|
735
770
|
}
|
|
736
771
|
}
|
|
737
772
|
|
|
773
|
+
// --- publisher signatures (see npm-signing.js) --------------------------------------------------
|
|
774
|
+
//
|
|
775
|
+
// A feed may list keys in package-feeds.json (feeds[].signing: {required, keys}), and its items
|
|
776
|
+
// may link a signature (fhir:signature). crawler.signatures says what to do with them:
|
|
777
|
+
// ignore - nothing
|
|
778
|
+
// check - (the default) check each signature of a feed that lists keys; one that fails is
|
|
779
|
+
// logged, and the package is stored anyway, unless the feed says signing.required
|
|
780
|
+
// require - as check, but an unsigned package, or one whose signature fails, is refused, for
|
|
781
|
+
// every feed that lists keys
|
|
782
|
+
// Feeds that list no keys are never affected. The signature file is kept (PackageSignatures) as
|
|
783
|
+
// it was when it was checked, valid or not.
|
|
784
|
+
|
|
785
|
+
// With crawler.signatures = require, a feed that lists no keys isn't read at all: none of its
|
|
786
|
+
// packages could be accepted. Noted in the crawl log.
|
|
787
|
+
skipUnsigned(feedConfig) {
|
|
788
|
+
if (this.signatureMode !== 'require' || this.signingFor(feedConfig.signing)) {
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
if (!this.skippedFeeds.has(feedConfig.url)) {
|
|
792
|
+
this.skippedFeeds.add(feedConfig.url);
|
|
793
|
+
this.log.info(`Skipping feed ${feedConfig.url}: it lists no signing keys, and signatures are required`);
|
|
794
|
+
this.crawlerLog.feeds.push({ url: feedConfig.url, skipped: 'it lists no signing keys, and signatures are required', items: [] });
|
|
795
|
+
}
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// How a feed's signatures are checked, or null if they aren't
|
|
800
|
+
signingFor(feedSigning) {
|
|
801
|
+
if (this.signatureMode === 'ignore' || !feedSigning || !Array.isArray(feedSigning.keys) || feedSigning.keys.length === 0) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
return { keys: feedSigning.keys, required: this.signatureMode === 'require' || feedSigning.required === true };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
signatureUrl(item) {
|
|
808
|
+
const s = item['fhir:signature'];
|
|
809
|
+
const v = s && typeof s === 'object' ? s['#text'] : s;
|
|
810
|
+
return v ? String(v).trim() : null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// The item's signature file: {url, text}, {url: null} if it has none, or {url, fetchError}
|
|
814
|
+
async fetchSignature(item) {
|
|
815
|
+
const url = this.signatureUrl(item);
|
|
816
|
+
if (!url) {
|
|
817
|
+
return { url: null };
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
const content = await this.fetchUrl(this.fixUrl(url), 'application/json');
|
|
821
|
+
if (content.length > MAX_SIGNATURE_BYTES) {
|
|
822
|
+
return { url, fetchError: `it is too large (${content.length} bytes)` };
|
|
823
|
+
}
|
|
824
|
+
return { url, text: content.toString('utf8') };
|
|
825
|
+
} catch (e) {
|
|
826
|
+
if (e.message.includes('RATE_LIMITED')) {
|
|
827
|
+
throw e;
|
|
828
|
+
}
|
|
829
|
+
return { url, fetchError: e.message };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Checks a package's signature. Returns {record} - what to keep in PackageSignatures - and
|
|
834
|
+
// {refuse} - why the package must not be stored - either or neither
|
|
835
|
+
checkSignature(signing, signature, packageBuffer, id, version, date, itemLog) {
|
|
836
|
+
if (!signing) {
|
|
837
|
+
return {};
|
|
838
|
+
}
|
|
839
|
+
if (!signature || !signature.url) {
|
|
840
|
+
itemLog.signature = 'unsigned';
|
|
841
|
+
return signing.required ? { refuse: 'it is not signed, and this feed\'s packages must be' } : {};
|
|
842
|
+
}
|
|
843
|
+
if (signature.fetchError) {
|
|
844
|
+
itemLog.signature = `the signature ${signature.url} could not be fetched: ${signature.fetchError}`;
|
|
845
|
+
this.log.error(`Signature for ${id}#${version} could not be fetched (${signature.url}): ${signature.fetchError}`);
|
|
846
|
+
return signing.required ? { refuse: itemLog.signature } : {};
|
|
847
|
+
}
|
|
848
|
+
const record = { url: signature.url, bundle: signature.text, keyid: null, valid: false, message: null };
|
|
849
|
+
try {
|
|
850
|
+
let bundle;
|
|
851
|
+
try {
|
|
852
|
+
bundle = JSON.parse(signature.text);
|
|
853
|
+
} catch (e) {
|
|
854
|
+
throw new Error('the signature file is not JSON');
|
|
855
|
+
}
|
|
856
|
+
const v = verifyPackageSignature(packageBuffer, bundle, signing.keys, id, version, date instanceof Date && !isNaN(date) ? date : null);
|
|
857
|
+
record.keyid = v.keyid;
|
|
858
|
+
record.valid = true;
|
|
859
|
+
itemLog.signature = `valid (${v.keyid})`;
|
|
860
|
+
return { record };
|
|
861
|
+
} catch (e) {
|
|
862
|
+
record.message = e.message;
|
|
863
|
+
itemLog.signature = `invalid: ${e.message}`;
|
|
864
|
+
this.log.error(`Signature check failed for ${id}#${version} (${signature.url}): ${e.message}`);
|
|
865
|
+
return { record, refuse: signing.required ? `its signature is not valid: ${e.message}` : null };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async recordSignature(id, version, record) {
|
|
870
|
+
await this.run(`INSERT OR REPLACE INTO PackageSignatures (PackageVersionKey, Url, KeyId, Valid, Message, Checked, Bundle)
|
|
871
|
+
SELECT PackageVersionKey, ?, ?, ?, ?, datetime('now'), ? FROM PackageVersions WHERE Id = ? AND Version = ?`,
|
|
872
|
+
[record.url, record.keyid, record.valid ? 1 : 0, record.message, record.bundle, id, version]);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// A package stored before its feed was signed (or before this server checked): its signature is
|
|
876
|
+
// checked against the stored bytes, once. Never refused - it is already held - but a failure is
|
|
877
|
+
// logged and kept. A signature that can't be fetched is tried again next crawl.
|
|
878
|
+
async checkStoredSignature(item, guid, signing, itemLog) {
|
|
879
|
+
if (!signing || !this.signatureUrl(item)) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const row = await new Promise((resolve, reject) => {
|
|
883
|
+
this.db.get(`SELECT v.PackageVersionKey, v.Id, v.Version, v.PubDate, s.PackageVersionKey AS Checked
|
|
884
|
+
FROM PackageVersions v LEFT JOIN PackageSignatures s ON s.PackageVersionKey = v.PackageVersionKey
|
|
885
|
+
WHERE v.GUID = ?`, [guid], (err, r) => err ? reject(err) : resolve(r));
|
|
886
|
+
});
|
|
887
|
+
if (!row || row.Checked) {
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const signature = await this.fetchSignature(item);
|
|
891
|
+
if (signature.fetchError) {
|
|
892
|
+
itemLog.signature = `the signature ${signature.url} could not be fetched: ${signature.fetchError}`;
|
|
893
|
+
this.log.warn(`Signature for ${row.Id}#${row.Version} could not be fetched (${signature.url}): ${signature.fetchError}`);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const content = await new Promise((resolve, reject) => {
|
|
897
|
+
this.db.get('SELECT Content FROM PackageVersions WHERE PackageVersionKey = ?', [row.PackageVersionKey], (err, r) => err ? reject(err) : resolve(r));
|
|
898
|
+
});
|
|
899
|
+
if (!content) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const verdict = this.checkSignature({ ...signing, required: false }, signature, content.Content, row.Id, row.Version, new Date(row.PubDate), itemLog);
|
|
903
|
+
if (verdict.record) {
|
|
904
|
+
await this.recordSignature(row.Id, row.Version, verdict.record);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
738
908
|
async hasStored(guid) {
|
|
739
909
|
return new Promise((resolve, reject) => {
|
|
740
910
|
this.db.get('SELECT COUNT(*) as count FROM PackageVersions WHERE GUID = ?', [guid], (err, row) => {
|
|
@@ -786,6 +956,55 @@ class PackageCrawler {
|
|
|
786
956
|
});
|
|
787
957
|
}
|
|
788
958
|
|
|
959
|
+
// Runs fn inside a database transaction: all of its writes commit, or none do. The crawler
|
|
960
|
+
// stores one package at a time, so its transactions never nest. The connection is shared
|
|
961
|
+
// with the web side, so a write it makes meanwhile (a download count) joins the transaction.
|
|
962
|
+
async transaction(fn) {
|
|
963
|
+
await this.run('BEGIN IMMEDIATE');
|
|
964
|
+
try {
|
|
965
|
+
const result = await fn();
|
|
966
|
+
await this.run('COMMIT');
|
|
967
|
+
return result;
|
|
968
|
+
} catch (e) {
|
|
969
|
+
try {
|
|
970
|
+
await this.run('ROLLBACK');
|
|
971
|
+
} catch (rollbackError) {
|
|
972
|
+
this.log?.error?.(`Rollback failed: ${rollbackError.message}`);
|
|
973
|
+
}
|
|
974
|
+
throw e;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Removes mirror files that a crawler stopped before renaming into place. Only files old
|
|
979
|
+
// enough that nothing can still be writing them: the mirror may be shared, and in a
|
|
980
|
+
// container the pid in the name is no guide.
|
|
981
|
+
cleanMirrorTempFiles(maxAgeMs = MIRROR_TMP_MAX_AGE_MS) {
|
|
982
|
+
if (!this.config.mirrorPath || !fs.existsSync(this.config.mirrorPath)) {
|
|
983
|
+
return 0;
|
|
984
|
+
}
|
|
985
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
986
|
+
let removed = 0;
|
|
987
|
+
for (const name of fs.readdirSync(this.config.mirrorPath)) {
|
|
988
|
+
if (!name.includes(MIRROR_TMP_MARKER)) {
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
const file = path.join(this.config.mirrorPath, name);
|
|
992
|
+
try {
|
|
993
|
+
const stat = fs.statSync(file);
|
|
994
|
+
if (stat.isFile() && stat.mtimeMs < cutoff) {
|
|
995
|
+
fs.rmSync(file, { force: true });
|
|
996
|
+
removed++;
|
|
997
|
+
}
|
|
998
|
+
} catch (e) {
|
|
999
|
+
this.log?.warn?.(`Could not remove stale mirror file ${name}: ${e.message}`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (removed > 0) {
|
|
1003
|
+
this.log?.info?.(`Removed ${removed} stale temporary file(s) from the package mirror`);
|
|
1004
|
+
}
|
|
1005
|
+
return removed;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
789
1008
|
// Point Packages.CurrentVersion at the most recent version of id
|
|
790
1009
|
async refreshCurrentVersion(id) {
|
|
791
1010
|
const latest = await new Promise((resolve, reject) => {
|
|
@@ -801,7 +1020,7 @@ class PackageCrawler {
|
|
|
801
1020
|
|
|
802
1021
|
// A direct copy replacing the Simplifier copies we held: it takes over the first row (so its
|
|
803
1022
|
// key and download count survive), and any further Simplifier rows go.
|
|
804
|
-
async replaceVersion(keys, packageBuffer, npmPackage, date, guid, id, canonical, urls) {
|
|
1023
|
+
async replaceVersion(keys, packageBuffer, npmPackage, date, guid, id, version, canonical, urls) {
|
|
805
1024
|
const [vkey, ...extra] = keys;
|
|
806
1025
|
await this.run(`UPDATE PackageVersions
|
|
807
1026
|
SET GUID = ?, PubDate = ?, Indexed = datetime('now'), Kind = ?, Canonical = ?,
|
|
@@ -812,7 +1031,7 @@ class PackageCrawler {
|
|
|
812
1031
|
npmPackage.description, this.genHash(packageBuffer), npmPackage.author, npmPackage.license,
|
|
813
1032
|
npmPackage.url, packageBuffer, vkey]);
|
|
814
1033
|
for (const key of keys) {
|
|
815
|
-
for (const table of ['PackageFHIRVersions', 'PackageDependencies', 'PackageURLs']) {
|
|
1034
|
+
for (const table of ['PackageFHIRVersions', 'PackageDependencies', 'PackageURLs', 'PackageDist', 'PackageSignatures']) {
|
|
816
1035
|
await this.run(`DELETE FROM ${table} WHERE PackageVersionKey = ?`, [key]);
|
|
817
1036
|
}
|
|
818
1037
|
}
|
|
@@ -820,6 +1039,7 @@ class PackageCrawler {
|
|
|
820
1039
|
await this.run('DELETE FROM PackageVersions WHERE PackageVersionKey = ?', [key]);
|
|
821
1040
|
}
|
|
822
1041
|
await this.insertRelatedData(vkey, npmPackage, urls);
|
|
1042
|
+
await this.storeDist(vkey, id, version, packageBuffer);
|
|
823
1043
|
await this.refreshCurrentVersion(id);
|
|
824
1044
|
}
|
|
825
1045
|
|
|
@@ -848,7 +1068,8 @@ class PackageCrawler {
|
|
|
848
1068
|
return date;
|
|
849
1069
|
}
|
|
850
1070
|
|
|
851
|
-
|
|
1071
|
+
// signing and signature: the feed's signature checking (signingFor) and what fetchSignature got
|
|
1072
|
+
async store(source, url, guid, date, packageBuffer, idver, itemLog, signing = null, signature = null) {
|
|
852
1073
|
try {
|
|
853
1074
|
// Extract and parse the NPM package
|
|
854
1075
|
const npmPackage = await this.extractNpmPackage(packageBuffer, `${source}#${guid}`);
|
|
@@ -876,48 +1097,63 @@ class PackageCrawler {
|
|
|
876
1097
|
throw new Error(check.reason);
|
|
877
1098
|
}
|
|
878
1099
|
|
|
879
|
-
|
|
880
|
-
throw new Error(`NPM Version "${version}" is not valid from ${source}`);
|
|
881
|
-
}
|
|
1100
|
+
const canonical = this.checkPackageContent(npmPackage, idver, source);
|
|
882
1101
|
|
|
883
|
-
|
|
884
|
-
if (
|
|
885
|
-
throw new Error(`
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
const isTemplate = npmPackage.kind === 2; // fhir.template
|
|
889
|
-
if (npmPackage.hasInstallScripts) {
|
|
890
|
-
throw new Error(`Package ${idver} rejected: contains install scripts (preinstall/install/postinstall)`);
|
|
891
|
-
}
|
|
892
|
-
if (npmPackage.hasJavaScript && !isTemplate && id !== 'hl7.fhir.pubpack') {
|
|
893
|
-
throw new Error(`Package ${idver} rejected: contains JavaScript files but is not a template package`);
|
|
894
|
-
}
|
|
895
|
-
// The feed gate (item.notForPublication) only sees the RSS entry. A package whose
|
|
896
|
-
// feed entry is clean can still carry notForPublication inside the tarball - that is
|
|
897
|
-
// a draft build that must never enter the registry. Reject it here too.
|
|
898
|
-
if (npmPackage.notForPublication) {
|
|
899
|
-
throw new Error(`Package ${idver} rejected: tarball is flagged notForPublication (draft build, not suitable for publication)`);
|
|
1102
|
+
const verdict = this.checkSignature(signing, signature, packageBuffer, id, version, date, itemLog);
|
|
1103
|
+
if (verdict.refuse) {
|
|
1104
|
+
throw new Error(`Package ${idver} rejected: ${verdict.refuse}`);
|
|
900
1105
|
}
|
|
901
1106
|
|
|
902
1107
|
// Extract URLs from package
|
|
903
1108
|
const urls = this.processPackageUrls(npmPackage);
|
|
904
1109
|
|
|
905
|
-
//
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
//
|
|
909
|
-
//
|
|
1110
|
+
// The mirror file has to be complete before the registry advertises the package (the
|
|
1111
|
+
// download redirect points at it), and a replacement must not touch the file served for
|
|
1112
|
+
// the copy we hold unless it commits. So: write the new file beside its target first;
|
|
1113
|
+
// a new package's file is put in place before the transaction, a replacement's at the
|
|
1114
|
+
// end of it, just before COMMIT, so a failed rename rolls the replacement back.
|
|
1115
|
+
// Rename within a directory is atomic, so readers see the old file or the whole new one.
|
|
1116
|
+
let filepath = null;
|
|
1117
|
+
let tmpPath = null;
|
|
910
1118
|
if (this.config.mirrorPath) {
|
|
911
1119
|
let fid = this.fixPrefix(id);
|
|
912
|
-
|
|
913
|
-
const filepath = path.join(this.config.mirrorPath, filename);
|
|
1120
|
+
filepath = path.join(this.config.mirrorPath, `${fid}-${version}.tgz`);
|
|
914
1121
|
// id and version are validated, but the file must land in the mirror whatever they say
|
|
915
1122
|
if (path.dirname(path.resolve(filepath)) !== path.resolve(this.config.mirrorPath)) {
|
|
916
1123
|
throw new Error(`Package ${id}#${version} rejected: its mirror file name is not a plain file name`);
|
|
917
1124
|
}
|
|
918
|
-
|
|
1125
|
+
tmpPath = `${filepath}${MIRROR_TMP_MARKER}${process.pid}`;
|
|
1126
|
+
}
|
|
1127
|
+
const replacing = check.action === 'replace';
|
|
1128
|
+
let placed = false;
|
|
1129
|
+
try {
|
|
1130
|
+
if (tmpPath) {
|
|
1131
|
+
fs.writeFileSync(tmpPath, packageBuffer);
|
|
1132
|
+
if (!replacing) {
|
|
1133
|
+
fs.renameSync(tmpPath, filepath);
|
|
1134
|
+
placed = true;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
await this.transaction(async () => {
|
|
1138
|
+
await this.commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls, check.keys);
|
|
1139
|
+
if (tmpPath && replacing) {
|
|
1140
|
+
fs.renameSync(tmpPath, filepath);
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
if (tmpPath) {
|
|
1145
|
+
fs.rmSync(tmpPath, { force: true });
|
|
1146
|
+
}
|
|
1147
|
+
if (placed) {
|
|
1148
|
+
// a new package that didn't commit isn't advertised, so its file goes too
|
|
1149
|
+
fs.rmSync(filepath, { force: true });
|
|
1150
|
+
}
|
|
1151
|
+
throw e;
|
|
919
1152
|
}
|
|
920
1153
|
|
|
1154
|
+
if (verdict.record) {
|
|
1155
|
+
await this.recordSignature(id, version, verdict.record);
|
|
1156
|
+
}
|
|
921
1157
|
return check.action === 'replace' ? 'replaced' : 'stored';
|
|
922
1158
|
|
|
923
1159
|
} catch (error) {
|
|
@@ -927,6 +1163,40 @@ class PackageCrawler {
|
|
|
927
1163
|
}
|
|
928
1164
|
}
|
|
929
1165
|
|
|
1166
|
+
// What a package must be to be stored (beyond being new): a valid id, a whole semver version,
|
|
1167
|
+
// an absolute canonical, no install scripts, no JavaScript unless it's a template, and not
|
|
1168
|
+
// flagged notForPublication. Throws saying what's wrong; returns the canonical. Shared with
|
|
1169
|
+
// the feed check (feed-check.js), which stores nothing.
|
|
1170
|
+
checkPackageContent(npmPackage, idver, source) {
|
|
1171
|
+
const {id, version} = npmPackage;
|
|
1172
|
+
if (!this.isValidPackageId(id)) {
|
|
1173
|
+
throw new Error(`NPM Id "${id}" is not valid from ${source}`);
|
|
1174
|
+
}
|
|
1175
|
+
if (!this.isValidSemVersion(version)) {
|
|
1176
|
+
throw new Error(`NPM Version "${version}" is not valid from ${source}`);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
let canonical = npmPackage.canonical || `http://simplifier.net/packages/${id}`;
|
|
1180
|
+
if (!this.isAbsoluteUrl(canonical)) {
|
|
1181
|
+
throw new Error(`NPM Canonical "${canonical}" is not valid from ${source}`);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const isTemplate = npmPackage.kind === 2; // fhir.template
|
|
1185
|
+
if (npmPackage.hasInstallScripts) {
|
|
1186
|
+
throw new Error(`Package ${idver} rejected: contains install scripts (preinstall/install/postinstall)`);
|
|
1187
|
+
}
|
|
1188
|
+
if (npmPackage.hasJavaScript && !isTemplate && id !== 'hl7.fhir.pubpack') {
|
|
1189
|
+
throw new Error(`Package ${idver} rejected: contains JavaScript files but is not a template package`);
|
|
1190
|
+
}
|
|
1191
|
+
// The feed gate (item.notForPublication) only sees the RSS entry. A package whose
|
|
1192
|
+
// feed entry is clean can still carry notForPublication inside the tarball - that is
|
|
1193
|
+
// a draft build that must never enter the registry. Reject it here too.
|
|
1194
|
+
if (npmPackage.notForPublication) {
|
|
1195
|
+
throw new Error(`Package ${idver} rejected: tarball is flagged notForPublication (draft build, not suitable for publication)`);
|
|
1196
|
+
}
|
|
1197
|
+
return canonical;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
930
1200
|
// Reads what the crawler needs from a package as the package is unzipped and untarred:
|
|
931
1201
|
// package.json, .index.json and ig.ini, the file names, and the url of each resource. Only
|
|
932
1202
|
// those files are held, one at a time, so memory is bounded by maxFileMB however large
|
|
@@ -1262,9 +1532,19 @@ class PackageCrawler {
|
|
|
1262
1532
|
return crypto.createHash('sha1').update(data).digest('hex');
|
|
1263
1533
|
}
|
|
1264
1534
|
|
|
1535
|
+
// The version's dist.integrity, and its registry signature when this server signs
|
|
1536
|
+
// (this.signer, set by the packages module - see npm-signing.js). In their own table,
|
|
1537
|
+
// PackageDist: a column after Content in PackageVersions can only be read by walking the
|
|
1538
|
+
// tarball's overflow pages, and the package document reads these for every version.
|
|
1539
|
+
async storeDist(vkey, id, version, packageBuffer) {
|
|
1540
|
+
const integrity = integrityOfSync(packageBuffer);
|
|
1541
|
+
await this.run('INSERT OR REPLACE INTO PackageDist (PackageVersionKey, Integrity, KeyId, Signature) VALUES (?, ?, ?, ?)',
|
|
1542
|
+
[vkey, integrity, this.signer ? this.signer.keyid : null, this.signer ? this.signer.sign(id, version, integrity) : null]);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1265
1545
|
async commit(packageBuffer, npmPackage, date, guid, id, version, canonical, urls, replaceKeys = null) {
|
|
1266
1546
|
if (replaceKeys && replaceKeys.length > 0) {
|
|
1267
|
-
return this.replaceVersion(replaceKeys, packageBuffer, npmPackage, date, guid, id, canonical, urls);
|
|
1547
|
+
return this.replaceVersion(replaceKeys, packageBuffer, npmPackage, date, guid, id, version, canonical, urls);
|
|
1268
1548
|
}
|
|
1269
1549
|
return new Promise((resolve, reject) => {
|
|
1270
1550
|
// Get next version key
|
|
@@ -1298,10 +1578,11 @@ class PackageCrawler {
|
|
|
1298
1578
|
}
|
|
1299
1579
|
|
|
1300
1580
|
// Insert FHIR versions, dependencies, and URLs
|
|
1301
|
-
this.insertRelatedData(vkey, npmPackage, urls)
|
|
1581
|
+
this.insertRelatedData(vkey, npmPackage, urls)
|
|
1582
|
+
.then(() => this.storeDist(vkey, id, version, packageBuffer))
|
|
1302
1583
|
// Handle package table (insert or update)
|
|
1303
|
-
this.upsertPackage(id, vkey, canonical)
|
|
1304
|
-
|
|
1584
|
+
.then(() => this.upsertPackage(id, vkey, canonical))
|
|
1585
|
+
.then(resolve).catch(reject);
|
|
1305
1586
|
});
|
|
1306
1587
|
});
|
|
1307
1588
|
});
|
|
@@ -54,13 +54,12 @@
|
|
|
54
54
|
<div id="stripe"> </div>
|
|
55
55
|
<div class="container"> <!-- container -->
|
|
56
56
|
<div style="background-color: #ad1f2f; padding: 6px; color: white;"> <!-- container -->
|
|
57
|
-
<a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> © HL7.org |
|
|
58
|
-
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] |
|
|
59
57
|
<a href="/" style="color: gold"> Server Home</a> |
|
|
60
58
|
<a href="/packages" style="color: gold">Packages Home</a> |
|
|
61
59
|
<a href="/packages/updates" style="color: gold">Updates</a> |
|
|
62
60
|
<a href="/packages/broken" style="color: gold">Broken</a> |
|
|
63
61
|
<a href="/packages/log" style="color: gold">Crawler Log</a> |
|
|
62
|
+
[%feed-check-link%]
|
|
64
63
|
<a href="/packages/openapi" style="color: gold">API</a>
|
|
65
64
|
</div> <!-- /container -->
|
|
66
65
|
</div> <!-- /container -->
|
|
@@ -74,7 +73,7 @@
|
|
|
74
73
|
<div class="inner-wrapper">
|
|
75
74
|
<div class="col-9">
|
|
76
75
|
|
|
77
|
-
<h2><img border="0" src="
|
|
76
|
+
<h2><a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" title="FHIRsmith [%ver%]"><img border="0" src="/FHIRsmith32.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/></a> [%title%] </h2>
|
|
78
77
|
|
|
79
78
|
[%content%]
|
|
80
79
|
|
|
@@ -92,8 +91,7 @@
|
|
|
92
91
|
<div class="container"> <!-- container -->
|
|
93
92
|
<div class="inner-wrapper">
|
|
94
93
|
<p>
|
|
95
|
-
<a href="
|
|
96
|
-
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] © HealthIntersections.com.au 2023+ |
|
|
94
|
+
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" alt="FHIRsmith Logo" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] © HealthIntersections.com.au 2023+ |
|
|
97
95
|
Package Registry [%crawler-status%] | [%total-packages%] packages |
|
|
98
96
|
([%ms%] ms)
|
|
99
97
|
[%sponsorMessage%]
|
|
@@ -117,10 +115,10 @@
|
|
|
117
115
|
================================================== -->
|
|
118
116
|
<!-- Placed at the end of the document so the pages load faster -->
|
|
119
117
|
<!-- <script src="/assets/js/jquery.js"/> -->
|
|
120
|
-
<script src="/assets/js/bootstrap.min.js"
|
|
121
|
-
<script src="/assets/js/respond.min.js"
|
|
118
|
+
<script src="/assets/js/bootstrap.min.js"></script>
|
|
119
|
+
<script src="/assets/js/respond.min.js"></script>
|
|
122
120
|
|
|
123
|
-
<script src="/assets/js/fhir.js"
|
|
121
|
+
<script src="/assets/js/fhir.js"></script>
|
|
124
122
|
|
|
125
123
|
<!-- Analytics Below
|
|
126
124
|
================================================== -->
|