fhirsmith 0.12.0 → 0.13.1
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 +323 -4
- package/README.md +1 -1
- package/config-template.json +13 -0
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +60 -30
- package/publisher/validation.js +15 -3
- package/server.js +12 -3
- package/translations/Messages.properties +1 -0
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-cs.js +14 -1
- package/tx/cs/cs-icd11.js +844 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +38 -4
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/provider.js +1 -1
- package/tx/sct/expressions.js +194 -17
- package/tx/tx.fhir.org.yml +2 -1
- package/tx/tx.js +15 -42
- package/tx/workers/batch-validate.js +6 -16
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +6 -12
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +36 -45
- package/tx/workers/lookup.js +21 -39
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +11 -17
- package/tx/workers/validate.js +76 -77
- package/tx/workers/worker.js +70 -1
- package/tx/xml/xml-base.js +243 -76
- package/tx/xversion/xv-valueset.js +39 -1
package/library/languages.js
CHANGED
|
@@ -62,7 +62,21 @@ class LanguageVariant extends LanguageEntry {}
|
|
|
62
62
|
* Individual language representation based on BCP 47
|
|
63
63
|
*/
|
|
64
64
|
class Language {
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} code - the language tag
|
|
67
|
+
* @param {LanguageDefinitions} [languageDefinitions] - registry to validate against; without
|
|
68
|
+
* one the tag is only broken into parts, not checked
|
|
69
|
+
* @param {object} [options] - extra checking, off by default so that existing callers
|
|
70
|
+
* (Accept-Language handling above all) keep the behaviour they were written against
|
|
71
|
+
* @param {boolean} [options.caseInsensitive] - fold subtags to their registry case before
|
|
72
|
+
* looking them up. BCP 47 s2.1.1 says tags are case-insensitive and the conventional
|
|
73
|
+
* casing is a recommendation, so `en-us` is a valid way to write `en-US`
|
|
74
|
+
* @param {boolean} [options.checkCombinations] - apply the registry's statements about
|
|
75
|
+
* which subtags may go together: the Prefix on an extlang or variant, and that a
|
|
76
|
+
* variant is registered at all
|
|
77
|
+
*/
|
|
78
|
+
constructor(code = '', languageDefinitions = null, options = {}) {
|
|
79
|
+
this.options = options || {};
|
|
66
80
|
this.code = code;
|
|
67
81
|
this.language = '';
|
|
68
82
|
this.extLang = [];
|
|
@@ -115,14 +129,26 @@ class Language {
|
|
|
115
129
|
_parse(languageDefinitions) {
|
|
116
130
|
if (!this.code) return;
|
|
117
131
|
|
|
132
|
+
// Grandfathered tags are whole tags, and most do not decompose into valid subtags
|
|
133
|
+
// (i-klingon has no language 'i'), so they have to be recognised before the tag is
|
|
134
|
+
// split up. Redundant entries are deliberately NOT here: zh-Hans decomposes, and
|
|
135
|
+
// matching it whole would rob the caller of the script.
|
|
136
|
+
if (languageDefinitions && languageDefinitions.wholeTags.has(this.code.toLowerCase())) {
|
|
137
|
+
const entry = languageDefinitions.wholeTags.get(this.code.toLowerCase());
|
|
138
|
+
this.language = entry.code;
|
|
139
|
+
this.wholeTag = entry;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fold = this.options.caseInsensitive;
|
|
118
144
|
const parts = this.code.split('-');
|
|
119
145
|
let index = 0;
|
|
120
146
|
|
|
121
147
|
// Language (required)
|
|
122
148
|
if (index < parts.length) {
|
|
123
|
-
this.language = parts[index];
|
|
149
|
+
this.language = fold ? parts[index].toLowerCase() : parts[index];
|
|
124
150
|
if (this.language != '*' && languageDefinitions && !languageDefinitions.languages.has(this.language)) {
|
|
125
|
-
throw new Error("The language '"+
|
|
151
|
+
throw new Error("The language '"+parts[index]+"' in the code '"+this.code+"' is not valid");
|
|
126
152
|
}
|
|
127
153
|
index++;
|
|
128
154
|
}
|
|
@@ -130,10 +156,14 @@ class Language {
|
|
|
130
156
|
// Extended language (up to 3)
|
|
131
157
|
for (let i = 0; i < 3 && index < parts.length; i++) {
|
|
132
158
|
const part = parts[index];
|
|
159
|
+
const key = fold ? part.toLowerCase() : part;
|
|
133
160
|
if (part.length === 3 && /^[a-zA-Z]{3}$/.test(part)) {
|
|
134
|
-
if (languageDefinitions && !languageDefinitions.extLanguages.has(
|
|
161
|
+
if (languageDefinitions && !languageDefinitions.extLanguages.has(key)) {
|
|
135
162
|
throw new Error("The extLanguage '"+part+"' in the code '"+this.code+"' is not valid");
|
|
136
163
|
}
|
|
164
|
+
if (this.options.checkCombinations && languageDefinitions) {
|
|
165
|
+
this._checkPrefix(languageDefinitions.extLanguages.get(key), 'extLanguage', part);
|
|
166
|
+
}
|
|
137
167
|
this.extLang.push(part.toLowerCase());
|
|
138
168
|
index++;
|
|
139
169
|
} else {
|
|
@@ -145,7 +175,8 @@ class Language {
|
|
|
145
175
|
if (index < parts.length) {
|
|
146
176
|
const part = parts[index];
|
|
147
177
|
if (part.length === 4 && /^[a-zA-Z]{4}$/.test(part)) {
|
|
148
|
-
|
|
178
|
+
const key = fold ? part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() : part;
|
|
179
|
+
if (languageDefinitions && !languageDefinitions.scripts.has(key)) {
|
|
149
180
|
throw new Error("The script '"+part+"' in the code '"+this.code+"' is not valid");
|
|
150
181
|
}
|
|
151
182
|
this.script = part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
|
|
@@ -158,7 +189,8 @@ class Language {
|
|
|
158
189
|
const part = parts[index];
|
|
159
190
|
if ((part.length === 2 && /^[a-zA-Z]{2}$/.test(part)) ||
|
|
160
191
|
(part.length === 3 && /^[0-9]{3}$/.test(part))) {
|
|
161
|
-
|
|
192
|
+
const key = fold ? part.toUpperCase() : part;
|
|
193
|
+
if (languageDefinitions && !languageDefinitions.regions.has(key)) {
|
|
162
194
|
throw new Error("The region '"+part+"' in the code '"+this.code+"' is not valid");
|
|
163
195
|
}
|
|
164
196
|
this.region = part.toUpperCase();
|
|
@@ -171,7 +203,16 @@ class Language {
|
|
|
171
203
|
const part = parts[index];
|
|
172
204
|
if ((part.length >= 5 && part.length <= 8) ||
|
|
173
205
|
(part.length === 4 && /^[0-9]/.test(part))) {
|
|
174
|
-
|
|
206
|
+
const key = part.toLowerCase();
|
|
207
|
+
if (this.options.checkCombinations && languageDefinitions) {
|
|
208
|
+
// The variant is the one subtag whose existence was never checked at all -
|
|
209
|
+
// 'en-abcdef' parsed happily with variant 'abcdef'.
|
|
210
|
+
if (!languageDefinitions.variants.has(key)) {
|
|
211
|
+
throw new Error("The variant '"+part+"' in the code '"+this.code+"' is not valid");
|
|
212
|
+
}
|
|
213
|
+
this._checkPrefix(languageDefinitions.variants.get(key), 'variant', part);
|
|
214
|
+
}
|
|
215
|
+
this.variant = key;
|
|
175
216
|
index++;
|
|
176
217
|
}
|
|
177
218
|
}
|
|
@@ -197,6 +238,36 @@ class Language {
|
|
|
197
238
|
}
|
|
198
239
|
}
|
|
199
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The registry's Prefix field names the language(s) a subtag may follow; it is the only
|
|
243
|
+
* thing lang.dat says about which subtags go together. An entry with no Prefix may
|
|
244
|
+
* follow anything.
|
|
245
|
+
*
|
|
246
|
+
* The prefix itself may be a multi-subtag tag (variant 'nedis' has Prefix 'sl-rozaj'),
|
|
247
|
+
* so what has been parsed so far is reassembled and compared from the left.
|
|
248
|
+
*
|
|
249
|
+
* @param {object} entry - the registry entry, carrying `prefixes`
|
|
250
|
+
* @param {string} what - 'extLanguage' or 'variant', for the message
|
|
251
|
+
* @param {string} part - the subtag as written, for the message
|
|
252
|
+
* @private
|
|
253
|
+
*/
|
|
254
|
+
_checkPrefix(entry, what, part) {
|
|
255
|
+
if (!entry || !entry.prefixes || entry.prefixes.length === 0) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const soFar = [this.language.toLowerCase()]
|
|
259
|
+
.concat(this.extLang.map(e => e.toLowerCase()))
|
|
260
|
+
.concat(this.script ? [this.script.toLowerCase()] : [])
|
|
261
|
+
.concat(this.region ? [this.region.toLowerCase()] : [])
|
|
262
|
+
.join('-');
|
|
263
|
+
const ok = entry.prefixes.some(prefix => soFar === prefix || soFar.startsWith(prefix + '-'));
|
|
264
|
+
if (!ok) {
|
|
265
|
+
throw new Error("The " + what + " '" + part + "' in the code '" + this.code
|
|
266
|
+
+ "' may only be used with " + entry.prefixes.map(p => "'" + p + "'").join(' or ')
|
|
267
|
+
+ ", not with '" + soFar + "'");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
200
271
|
/**
|
|
201
272
|
* Check if this language matches another to a given depth
|
|
202
273
|
*/
|
|
@@ -503,9 +574,63 @@ class LanguageDefinitions {
|
|
|
503
574
|
this.scripts = new Map();
|
|
504
575
|
this.regions = new Map();
|
|
505
576
|
this.variants = new Map();
|
|
577
|
+
// Whole tags that predate the current syntax (i-klingon, en-GB-oed) or that the
|
|
578
|
+
// registry records as ready-made combinations (az-Arab). They are matched as
|
|
579
|
+
// complete tags, before the tag is broken into subtags, because most of them do
|
|
580
|
+
// not decompose into valid subtags at all. Keyed lower case: BCP 47 tags are
|
|
581
|
+
// case-insensitive, and the registry writes these in mixed case.
|
|
582
|
+
this.wholeTags = new Map();
|
|
506
583
|
this.parsed = new Map(); // Cache for parsed languages
|
|
507
584
|
}
|
|
508
585
|
|
|
586
|
+
/**
|
|
587
|
+
* Expand a registry subtag range into its members.
|
|
588
|
+
*
|
|
589
|
+
* The registry writes private-use allocations as ranges - `QM..QZ`, `Qaaa..Qabx`,
|
|
590
|
+
* `qaa..qtz`, `XA..XZ` - in a single record. Stored literally, the key `QM..QZ` is
|
|
591
|
+
* one nobody will ever look up, and every code in the range reads as unregistered:
|
|
592
|
+
* `en-QM` was rejected while `en-AA` (allocated singly) was accepted.
|
|
593
|
+
*
|
|
594
|
+
* Only the trailing letters vary, so the shared prefix is kept and the remainder is
|
|
595
|
+
* counted through base 26, rendered in the case the range itself uses.
|
|
596
|
+
*
|
|
597
|
+
* @param {string} subtag - a subtag, with or without `..`
|
|
598
|
+
* @returns {string[]} every code the entry covers; a single-element array if not a range
|
|
599
|
+
*/
|
|
600
|
+
static expandSubtagRange(subtag) {
|
|
601
|
+
const at = subtag.indexOf('..');
|
|
602
|
+
if (at === -1) {
|
|
603
|
+
return [subtag];
|
|
604
|
+
}
|
|
605
|
+
const from = subtag.substring(0, at);
|
|
606
|
+
const to = subtag.substring(at + 2);
|
|
607
|
+
if (from.length !== to.length || !/^[a-zA-Z]+$/.test(from) || !/^[a-zA-Z]+$/.test(to)) {
|
|
608
|
+
return [subtag];
|
|
609
|
+
}
|
|
610
|
+
let shared = 0;
|
|
611
|
+
while (shared < from.length && from[shared] === to[shared]) {
|
|
612
|
+
shared++;
|
|
613
|
+
}
|
|
614
|
+
const prefix = from.substring(0, shared);
|
|
615
|
+
const upper = /[A-Z]/.test(from.substring(shared));
|
|
616
|
+
const A = 'a'.charCodeAt(0);
|
|
617
|
+
const toIndex = (text) => [...text.toLowerCase()].reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - A), 0);
|
|
618
|
+
const width = from.length - shared;
|
|
619
|
+
const render = (n) => {
|
|
620
|
+
let out = '';
|
|
621
|
+
for (let i = 0; i < width; i++) {
|
|
622
|
+
out = String.fromCharCode(A + (n % 26)) + out;
|
|
623
|
+
n = Math.floor(n / 26);
|
|
624
|
+
}
|
|
625
|
+
return upper ? out.toUpperCase() : out;
|
|
626
|
+
};
|
|
627
|
+
const result = [];
|
|
628
|
+
for (let n = toIndex(from.substring(shared)); n <= toIndex(to.substring(shared)); n++) {
|
|
629
|
+
result.push(prefix + render(n));
|
|
630
|
+
}
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
|
|
509
634
|
/**
|
|
510
635
|
* Load definitions from IETF language subtag registry file
|
|
511
636
|
*/
|
|
@@ -564,8 +689,12 @@ class LanguageDefinitions {
|
|
|
564
689
|
this._loadVariant(vars);
|
|
565
690
|
break;
|
|
566
691
|
case 'grandfathered':
|
|
692
|
+
this._loadWholeTag(vars);
|
|
693
|
+
break;
|
|
567
694
|
case 'redundant':
|
|
568
|
-
//
|
|
695
|
+
// Nothing to do: a redundant entry is called that because it decomposes into
|
|
696
|
+
// subtags that already say the same thing (zh-Hans is zh + Hans), so the
|
|
697
|
+
// normal path handles it and gives the caller the parts as well.
|
|
569
698
|
break;
|
|
570
699
|
default:
|
|
571
700
|
throw new Error(`Unknown type: ${vars.Type} at line ${i + 1}`);
|
|
@@ -576,6 +705,24 @@ class LanguageDefinitions {
|
|
|
576
705
|
}
|
|
577
706
|
}
|
|
578
707
|
|
|
708
|
+
/**
|
|
709
|
+
* Load a grandfathered entry - a whole tag from before the current syntax, most of
|
|
710
|
+
* which cannot be decomposed into valid subtags at all ('i-klingon' has no language
|
|
711
|
+
* 'i'), so they are matched entire.
|
|
712
|
+
*/
|
|
713
|
+
_loadWholeTag(vars) {
|
|
714
|
+
const tag = vars.Tag || vars.Subtag;
|
|
715
|
+
if (!tag) {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
this.wholeTags.set(tag.toLowerCase(), {
|
|
719
|
+
code: tag,
|
|
720
|
+
displays: vars.Description ? vars.Description.split('|') : [],
|
|
721
|
+
deprecated: !!vars.Deprecated,
|
|
722
|
+
preferred: vars['Preferred-Value'] || ''
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
579
726
|
/**
|
|
580
727
|
* Read variables from the registry format
|
|
581
728
|
*/
|
|
@@ -696,11 +843,12 @@ class LanguageDefinitions {
|
|
|
696
843
|
lang.suppressScript = vars['Suppress-Script'] || '';
|
|
697
844
|
lang.scope = vars.Scope || '';
|
|
698
845
|
|
|
699
|
-
|
|
700
|
-
|
|
846
|
+
for (const code of LanguageDefinitions.expandSubtagRange(lang.code)) {
|
|
847
|
+
if (this.languages.has(code)) {
|
|
848
|
+
throw new Error(`Duplicate language code: ${code}`);
|
|
849
|
+
}
|
|
850
|
+
this.languages.set(code, lang.code === code ? lang : Object.assign(Object.create(Object.getPrototypeOf(lang)), lang, { code }));
|
|
701
851
|
}
|
|
702
|
-
|
|
703
|
-
this.languages.set(lang.code, lang);
|
|
704
852
|
}
|
|
705
853
|
|
|
706
854
|
/**
|
|
@@ -710,12 +858,15 @@ class LanguageDefinitions {
|
|
|
710
858
|
const extLang = new LanguageExtLang();
|
|
711
859
|
extLang.code = vars.Subtag;
|
|
712
860
|
extLang.displays = vars.Description ? vars.Description.split('|') : [];
|
|
861
|
+
// Prefix is the registry saying which language(s) this subtag may follow
|
|
862
|
+
extLang.prefixes = vars.Prefix ? vars.Prefix.split('|').map(v => v.trim().toLowerCase()) : [];
|
|
713
863
|
|
|
714
|
-
|
|
715
|
-
|
|
864
|
+
for (const code of LanguageDefinitions.expandSubtagRange(extLang.code)) {
|
|
865
|
+
if (this.extLanguages.has(code)) {
|
|
866
|
+
throw new Error(`Duplicate extlang code: ${code}`);
|
|
867
|
+
}
|
|
868
|
+
this.extLanguages.set(code, extLang.code === code ? extLang : Object.assign(Object.create(Object.getPrototypeOf(extLang)), extLang, { code }));
|
|
716
869
|
}
|
|
717
|
-
|
|
718
|
-
this.extLanguages.set(extLang.code, extLang);
|
|
719
870
|
}
|
|
720
871
|
|
|
721
872
|
/**
|
|
@@ -726,11 +877,12 @@ class LanguageDefinitions {
|
|
|
726
877
|
script.code = vars.Subtag;
|
|
727
878
|
script.displays = vars.Description ? vars.Description.split('|') : [];
|
|
728
879
|
|
|
729
|
-
|
|
730
|
-
|
|
880
|
+
for (const code of LanguageDefinitions.expandSubtagRange(script.code)) {
|
|
881
|
+
if (this.scripts.has(code)) {
|
|
882
|
+
throw new Error(`Duplicate script code: ${code}`);
|
|
883
|
+
}
|
|
884
|
+
this.scripts.set(code, script.code === code ? script : Object.assign(Object.create(Object.getPrototypeOf(script)), script, { code }));
|
|
731
885
|
}
|
|
732
|
-
|
|
733
|
-
this.scripts.set(script.code, script);
|
|
734
886
|
}
|
|
735
887
|
|
|
736
888
|
/**
|
|
@@ -741,11 +893,12 @@ class LanguageDefinitions {
|
|
|
741
893
|
region.code = vars.Subtag;
|
|
742
894
|
region.displays = vars.Description ? vars.Description.split('|') : [];
|
|
743
895
|
|
|
744
|
-
|
|
745
|
-
|
|
896
|
+
for (const code of LanguageDefinitions.expandSubtagRange(region.code)) {
|
|
897
|
+
if (this.regions.has(code)) {
|
|
898
|
+
throw new Error(`Duplicate region code: ${code}`);
|
|
899
|
+
}
|
|
900
|
+
this.regions.set(code, region.code === code ? region : Object.assign(Object.create(Object.getPrototypeOf(region)), region, { code }));
|
|
746
901
|
}
|
|
747
|
-
|
|
748
|
-
this.regions.set(region.code, region);
|
|
749
902
|
}
|
|
750
903
|
|
|
751
904
|
/**
|
|
@@ -755,12 +908,14 @@ class LanguageDefinitions {
|
|
|
755
908
|
const variant = new LanguageVariant();
|
|
756
909
|
variant.code = vars.Subtag;
|
|
757
910
|
variant.displays = vars.Description ? vars.Description.split('|') : [];
|
|
911
|
+
variant.prefixes = vars.Prefix ? vars.Prefix.split('|').map(v => v.trim().toLowerCase()) : [];
|
|
758
912
|
|
|
759
|
-
|
|
760
|
-
|
|
913
|
+
for (const code of LanguageDefinitions.expandSubtagRange(variant.code)) {
|
|
914
|
+
if (this.variants.has(code)) {
|
|
915
|
+
throw new Error(`Duplicate variant code: ${code}`);
|
|
916
|
+
}
|
|
917
|
+
this.variants.set(code, variant.code === code ? variant : Object.assign(Object.create(Object.getPrototypeOf(variant)), variant, { code }));
|
|
761
918
|
}
|
|
762
|
-
|
|
763
|
-
this.variants.set(variant.code, variant);
|
|
764
919
|
}
|
|
765
920
|
|
|
766
921
|
/**
|
|
@@ -768,7 +923,7 @@ class LanguageDefinitions {
|
|
|
768
923
|
*
|
|
769
924
|
* @return {Language} parsed language (or null)
|
|
770
925
|
*/
|
|
771
|
-
parse(code, msg) {
|
|
926
|
+
parse(code, msg, options = {}) {
|
|
772
927
|
if (!code) {
|
|
773
928
|
if (msg) {
|
|
774
929
|
msg.message = 'No code provided';
|
|
@@ -776,15 +931,17 @@ class LanguageDefinitions {
|
|
|
776
931
|
return null;
|
|
777
932
|
}
|
|
778
933
|
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
|
|
934
|
+
// The cache key carries the options: the same tag can be valid under one set of
|
|
935
|
+
// rules and not another, so a lenient parse must not be served to a strict caller.
|
|
936
|
+
const key = (options.caseInsensitive ? 'i' : '') + (options.checkCombinations ? 'c' : '') + ':' + code;
|
|
937
|
+
if (this.parsed.has(key)) {
|
|
938
|
+
return this.parsed.get(key);
|
|
782
939
|
}
|
|
783
940
|
|
|
784
941
|
try {
|
|
785
|
-
const lang = new Language(code, this);
|
|
942
|
+
const lang = new Language(code, this, options);
|
|
786
943
|
// Cache the result
|
|
787
|
-
this.parsed.set(
|
|
944
|
+
this.parsed.set(key, lang);
|
|
788
945
|
return lang;
|
|
789
946
|
} catch (e) {
|
|
790
947
|
if (msg) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containment for filesystem paths built from request URLs.
|
|
3
|
+
*
|
|
4
|
+
* The rule everywhere a URL path is turned into a filename: resolve first, then check
|
|
5
|
+
* that the result is still inside the directory you meant to serve. `path.join` does
|
|
6
|
+
* not do this - it resolves `..` happily and will walk straight out of the directory.
|
|
7
|
+
*
|
|
8
|
+
* Note that Express does not URL-decode `req.path`, so `%2e%2e` never arrives here as
|
|
9
|
+
* `..`. A literal `..` does: nothing stops a client from sending `GET /../../etc/passwd`
|
|
10
|
+
* on a raw socket, and only the browsers and proxies in between normalise it away.
|
|
11
|
+
*
|
|
12
|
+
* @module library/path-safety
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a URL path against a root directory, refusing anything that escapes it.
|
|
19
|
+
*
|
|
20
|
+
* The trailing separator in the comparison is load-bearing: without it a sibling
|
|
21
|
+
* directory whose name merely starts with the same characters ('/srv/site' vs
|
|
22
|
+
* '/srv/site-backup') would be accepted as inside the root.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} rootDir - Directory that the result must stay inside. Resolved first,
|
|
25
|
+
* so a relative or non-normalised root is fine.
|
|
26
|
+
* @param {string} urlPath - Path from the request (e.g. `req.path`), leading slash and all.
|
|
27
|
+
* @returns {string|null} The absolute path, or null if it would escape rootDir. The root
|
|
28
|
+
* directory itself is allowed through; callers that only want files should stat it.
|
|
29
|
+
*/
|
|
30
|
+
function resolveWithin(rootDir, urlPath) {
|
|
31
|
+
if (typeof rootDir !== 'string' || typeof urlPath !== 'string') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
// A NUL truncates the name inside libc, so a path containing one is never what it
|
|
35
|
+
// appears to be. Node throws on these anyway; refusing here keeps the answer a null
|
|
36
|
+
// rather than an exception from whatever the caller does next.
|
|
37
|
+
if (urlPath.includes('\0')) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const root = path.resolve(rootDir);
|
|
41
|
+
// '.' + urlPath keeps the join relative: resolve() would otherwise treat a leading
|
|
42
|
+
// slash as an absolute path and discard the root entirely.
|
|
43
|
+
const full = path.resolve(root, '.' + (urlPath.startsWith('/') ? urlPath : '/' + urlPath));
|
|
44
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
45
|
+
if (full !== root && !full.startsWith(prefix)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return full;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { resolveWithin };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-secret checks for administrative endpoints.
|
|
3
|
+
*
|
|
4
|
+
* These are endpoints that do something expensive or destructive and have no user
|
|
5
|
+
* behind them - a crawl trigger, a cache flush - so they are gated on a token from the
|
|
6
|
+
* configuration rather than on a login. Two rules make that safe, and both are easy to
|
|
7
|
+
* get wrong by hand:
|
|
8
|
+
*
|
|
9
|
+
* - Fail closed. If no token is configured the endpoint is CLOSED, not open. An
|
|
10
|
+
* administrative endpoint that quietly defaults to unauthenticated is worse than one
|
|
11
|
+
* that is missing, because nothing about the deployment looks wrong.
|
|
12
|
+
* - Compare in constant time. A byte-at-a-time comparison leaks the token one
|
|
13
|
+
* character per round trip to anyone who can measure it.
|
|
14
|
+
*
|
|
15
|
+
* @module library/request-token
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Does `supplied` match the configured token?
|
|
22
|
+
*
|
|
23
|
+
* @param {string|undefined|null} configured - The token from configuration. Anything
|
|
24
|
+
* empty or non-string means the endpoint is not configured, and the answer is always
|
|
25
|
+
* false - callers should say so rather than reporting a bad token.
|
|
26
|
+
* @param {*} supplied - The token from the request (a header value). Non-strings, and
|
|
27
|
+
* the array Node produces for a repeated header, are rejected.
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
function tokenMatches(configured, supplied) {
|
|
31
|
+
if (typeof configured !== 'string' || configured.length === 0) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (typeof supplied !== 'string' || supplied.length === 0) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const a = Buffer.from(configured, 'utf8');
|
|
38
|
+
const b = Buffer.from(supplied, 'utf8');
|
|
39
|
+
// timingSafeEqual throws unless the lengths match, and the length itself is not a
|
|
40
|
+
// secret worth protecting - but returning early on it would still be a comparison
|
|
41
|
+
// whose cost depends on the input, so hash both to a fixed width and compare that.
|
|
42
|
+
const ha = crypto.createHash('sha256').update(a).digest();
|
|
43
|
+
const hb = crypto.createHash('sha256').update(b).digest();
|
|
44
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Is this endpoint configured with a token at all?
|
|
49
|
+
* @param {string|undefined|null} configured
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function tokenConfigured(configured) {
|
|
53
|
+
return typeof configured === 'string' && configured.length > 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { tokenMatches, tokenConfigured };
|
package/package.json
CHANGED
package/packages/packages.js
CHANGED
|
@@ -16,6 +16,7 @@ const escape = require('escape-html');
|
|
|
16
16
|
const Logger = require('../library/logger');
|
|
17
17
|
const {validateParameter} = require("../library/utilities");
|
|
18
18
|
const {describeCron} = require("../library/cron-utilities");
|
|
19
|
+
const {tokenMatches, tokenConfigured} = require("../library/request-token");
|
|
19
20
|
const pckLog = Logger.getInstance().child({ module: 'packages' });
|
|
20
21
|
|
|
21
22
|
class PackagesModule {
|
|
@@ -592,75 +593,6 @@ class PackagesModule {
|
|
|
592
593
|
}
|
|
593
594
|
}
|
|
594
595
|
|
|
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
|
-
const buffer = await this.crawler.fetchUrl(link);
|
|
609
|
-
const npm = await this.crawler.extractNpmPackage(buffer, link);
|
|
610
|
-
|
|
611
|
-
// Refuse to re-store a still-broken package - that would just re-publish the bug.
|
|
612
|
-
if (npm.notForPublication) {
|
|
613
|
-
throw new Error('Refusing to store ' + npm.id + '#' + npm.version + ': fetched tarball is still flagged notForPublication');
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const idver = npm.id + '#' + npm.version;
|
|
617
|
-
|
|
618
|
-
// Delete whatever is stored for this package version, matching on the id#version
|
|
619
|
-
// actually found in the fetched tarball. This used to match on GUID = link, but the
|
|
620
|
-
// stored GUID is whatever the feed said at crawl time (http vs https, or another
|
|
621
|
-
// permalink form), so an exact string match could silently delete nothing and leave
|
|
622
|
-
// the stale copy in place alongside the newly stored one.
|
|
623
|
-
const old = await this.deleteVersionsByIdVersion(npm.id, npm.version);
|
|
624
|
-
|
|
625
|
-
// Keep the old row's GUID when there was one - it is the feed's permalink, and
|
|
626
|
-
// reusing it stops the next crawl from re-inserting the package as a duplicate.
|
|
627
|
-
// When we never had the package, the link is the best available GUID (the feeds
|
|
628
|
-
// use the versioned package.tgz url as the permalink GUID anyway).
|
|
629
|
-
const guid = old.guids[0] || link;
|
|
630
|
-
|
|
631
|
-
const itemLog = { status: '??' };
|
|
632
|
-
await this.crawler.store(link, link, guid, new Date(), buffer, idver, itemLog);
|
|
633
|
-
|
|
634
|
-
pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' + old.count + ' existing row(s), guid ' + guid + ')');
|
|
635
|
-
return { status: 'updated', id: npm.id, version: npm.version, replaced: old.count };
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// Delete a stored package version and all of its child rows, by package id and
|
|
639
|
-
// version. Resolves to { count, guids }: how many PackageVersions rows went, and
|
|
640
|
-
// the distinct GUIDs they were stored under (so a caller can reuse the permalink).
|
|
641
|
-
deleteVersionsByIdVersion(id, version) {
|
|
642
|
-
return new Promise((resolve, reject) => {
|
|
643
|
-
this.db.all('SELECT PackageVersionKey, GUID FROM PackageVersions WHERE Id = ? AND Version = ?', [id, version], (err, rows) => {
|
|
644
|
-
if (err) return reject(err);
|
|
645
|
-
const keys = (rows || []).map(r => r.PackageVersionKey);
|
|
646
|
-
const guids = [...new Set((rows || []).map(r => r.GUID))];
|
|
647
|
-
if (keys.length === 0) return resolve({ count: 0, guids: [] });
|
|
648
|
-
const ph = keys.map(() => '?').join(',');
|
|
649
|
-
const stmts = [
|
|
650
|
-
'DELETE FROM PackageFHIRVersions WHERE PackageVersionKey IN (' + ph + ')',
|
|
651
|
-
'DELETE FROM PackageDependencies WHERE PackageVersionKey IN (' + ph + ')',
|
|
652
|
-
'DELETE FROM PackageURLs WHERE PackageVersionKey IN (' + ph + ')',
|
|
653
|
-
'DELETE FROM PackageVersions WHERE PackageVersionKey IN (' + ph + ')'
|
|
654
|
-
];
|
|
655
|
-
const runNext = (i) => {
|
|
656
|
-
if (i >= stmts.length) return resolve({ count: keys.length, guids });
|
|
657
|
-
this.db.run(stmts[i], keys, (e) => e ? reject(e) : runNext(i + 1));
|
|
658
|
-
};
|
|
659
|
-
runNext(0);
|
|
660
|
-
});
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
|
|
664
596
|
async initializeDatabase() {
|
|
665
597
|
return new Promise((resolve, reject) => {
|
|
666
598
|
// Use config path if absolute, otherwise resolve relative to data dir
|
|
@@ -1269,10 +1201,25 @@ class PackagesModule {
|
|
|
1269
1201
|
}
|
|
1270
1202
|
});
|
|
1271
1203
|
|
|
1272
|
-
// Manual crawler trigger
|
|
1204
|
+
// Manual crawler trigger. A crawl is a long, network-heavy job that anyone could
|
|
1205
|
+
// otherwise start at will, so it is gated on a shared secret from the configuration
|
|
1206
|
+
// (modules.packages.crawlToken), sent in the x-crawl-token header. With no token
|
|
1207
|
+
// configured the endpoint is CLOSED - an administrative trigger that silently
|
|
1208
|
+
// defaults to open is how a deployment ends up exposed without anything looking wrong.
|
|
1273
1209
|
this.router.post('/crawl', async (req, res) => {
|
|
1274
1210
|
const start = Date.now();
|
|
1275
1211
|
try {
|
|
1212
|
+
if (!tokenConfigured(this.config.crawlToken)) {
|
|
1213
|
+
// Say which of the two it is: the operator needs to know the endpoint is off
|
|
1214
|
+
// because it was never configured, not that they typed the token wrongly.
|
|
1215
|
+
res.status(403).json({ error: 'forbidden: no crawlToken is configured, so this endpoint is disabled' });
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (!tokenMatches(this.config.crawlToken, req.headers['x-crawl-token'])) {
|
|
1219
|
+
pckLog.warn('Rejected /crawl: missing or invalid x-crawl-token (from ' + req.ip + ')');
|
|
1220
|
+
res.status(403).json({ error: 'forbidden: missing or invalid x-crawl-token' });
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1276
1223
|
try {
|
|
1277
1224
|
await this.runCrawler();
|
|
1278
1225
|
res.json({
|
|
@@ -1292,54 +1239,6 @@ class PackagesModule {
|
|
|
1292
1239
|
}
|
|
1293
1240
|
});
|
|
1294
1241
|
|
|
1295
|
-
// Force-refresh specific packages, bypassing the feed. The crawler only fetches a
|
|
1296
|
-
// package once (it dedupes on the feed GUID) and skips anything flagged
|
|
1297
|
-
// notForPublication, so there is normally no way to make it re-pick-up a package
|
|
1298
|
-
// that was published incorrectly and later corrected. This endpoint re-fetches the
|
|
1299
|
-
// tarball(s) directly and replaces whatever is stored.
|
|
1300
|
-
//
|
|
1301
|
-
// POST /update-package { "links": ["http://hl7.org/fhir/uv/ips/2.0.1/package.tgz", ...] }
|
|
1302
|
-
//
|
|
1303
|
-
// The link is the versioned package.tgz url. The stored copy to replace is found by
|
|
1304
|
-
// the id#version inside the fetched tarball (not by matching the link against the
|
|
1305
|
-
// stored GUID, which can differ in scheme or form from what the feed published), and
|
|
1306
|
-
// the replacement keeps the old row's GUID so a later crawl won't create a duplicate.
|
|
1307
|
-
// If config.updateToken is set, the request must carry it in the x-update-token header.
|
|
1308
|
-
this.router.post('/update-package', async (req, res) => {
|
|
1309
|
-
const start = Date.now();
|
|
1310
|
-
try {
|
|
1311
|
-
if (this.config.updateToken && req.headers['x-update-token'] !== this.config.updateToken) {
|
|
1312
|
-
res.status(403).json({ error: 'forbidden: missing or invalid x-update-token' });
|
|
1313
|
-
return;
|
|
1314
|
-
}
|
|
1315
|
-
let links = req.body && (req.body.links || req.body.packages || (req.body.url ? [req.body.url] : (req.body.link ? [req.body.link] : null)));
|
|
1316
|
-
if (typeof links === 'string') links = [links];
|
|
1317
|
-
if (!Array.isArray(links) || links.length === 0) {
|
|
1318
|
-
res.status(400).json({ error: 'Provide a JSON body like {"links": ["<package.tgz url>", ...]}' });
|
|
1319
|
-
return;
|
|
1320
|
-
}
|
|
1321
|
-
const results = [];
|
|
1322
|
-
for (const link of links) {
|
|
1323
|
-
try {
|
|
1324
|
-
results.push(Object.assign({ link }, await this.forceUpdatePackage(link)));
|
|
1325
|
-
} catch (e) {
|
|
1326
|
-
pckLog.error('Force update failed for ' + link + ': ' + e.message);
|
|
1327
|
-
results.push({ link, status: 'error', error: e.message });
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
const failed = results.filter(r => r.status === 'error').length;
|
|
1331
|
-
res.status(failed === results.length ? 500 : 200).json({
|
|
1332
|
-
message: 'Processed ' + results.length + ' package(s), ' + failed + ' failed',
|
|
1333
|
-
results
|
|
1334
|
-
});
|
|
1335
|
-
} catch (error) {
|
|
1336
|
-
pckLog.error('update-package endpoint failed:', error);
|
|
1337
|
-
res.status(500).json({ error: 'update-package failed', message: error.message });
|
|
1338
|
-
} finally {
|
|
1339
|
-
this.stats.countRequest('update-package', Date.now() - start);
|
|
1340
|
-
}
|
|
1341
|
-
});
|
|
1342
|
-
|
|
1343
1242
|
// Crawler statistics endpoint (existing)
|
|
1344
1243
|
this.router.get('/stats', async (req, res) => {
|
|
1345
1244
|
const start = Date.now();
|