fhirsmith 0.15.0 → 0.16.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 -1
- package/config-template.json +10 -0
- package/library/npm-audit.js +131 -11
- package/library/sqlite-session-store.js +133 -0
- package/package.json +19 -29
- package/packages/npm-check-page.js +108 -0
- package/packages/npm-check.js +450 -0
- package/packages/openapi.yaml +158 -5
- package/packages/package-crawler.js +203 -20
- package/packages/packages.js +250 -8
- package/packages/readme.md +55 -2
- package/packages/utilities/dedupe-versions.js +242 -0
- package/registry/registry.js +10 -3
- package/token/token.js +7 -10
- package/translations/Messages.properties +3 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-icd11.js +23 -6
- package/tx/cs/icd11-expressions.js +5 -6
- package/tx/importers/icd11-schema.md +1 -0
- package/tx/importers/import-icd11.js +39 -0
- package/tx/importers/import-rxnorm.module.js +1 -1
- package/tx/importers/import-sct.module.js +1 -1
- package/tx/library/designations.js +1 -1
- package/tx/sct/text-index.js +1 -1
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/workers/lookup.js +7 -4
package/packages/packages.js
CHANGED
|
@@ -19,6 +19,9 @@ const {describeCron} = require("../library/cron-utilities");
|
|
|
19
19
|
const {tokenMatches, tokenConfigured} = require("../library/request-token");
|
|
20
20
|
const pckLog = Logger.getInstance().child({ module: 'packages' });
|
|
21
21
|
const packagesOpenApi = require('./openapi');
|
|
22
|
+
const rateLimit = require('express-rate-limit');
|
|
23
|
+
const { runNpmCheck, makeGetter } = require('./npm-check');
|
|
24
|
+
const npmCheckPage = require('./npm-check-page');
|
|
22
25
|
|
|
23
26
|
// Query parameter rules for validateQueryParams. These are also the contract published in
|
|
24
27
|
// openapi.yaml, and tests/packages/openapi.test.js checks that the two agree - so change
|
|
@@ -79,6 +82,39 @@ const PACKAGE_ID_MAX_LENGTH = 100;
|
|
|
79
82
|
const PACKAGE_VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
80
83
|
const PACKAGE_VERSION_MAX_LENGTH = 50;
|
|
81
84
|
|
|
85
|
+
// GET /npm-check: run the npm registry protocol checks against a registry (also in openapi.yaml)
|
|
86
|
+
const NPM_CHECK_PARAMS = {
|
|
87
|
+
registry: { maxLength: 500, pattern: /^https?:\/\/[^\s<>"'`]+$/ },
|
|
88
|
+
package: { maxLength: PACKAGE_ID_MAX_LENGTH, pattern: PACKAGE_ID_PATTERN },
|
|
89
|
+
version: { maxLength: PACKAGE_VERSION_MAX_LENGTH, pattern: PACKAGE_VERSION_PATTERN }
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// npm-check defaults; all can be set under modules.packages.npmCheck in the config
|
|
93
|
+
const NPM_CHECK_DEFAULTS = {
|
|
94
|
+
enabled: true,
|
|
95
|
+
maxConcurrentRuns: 2, // across all users; more get a 503
|
|
96
|
+
runsPerWindow: 10, // per client IP, per 10 minutes
|
|
97
|
+
requestTimeout: 30000, // ms, per request the checks make
|
|
98
|
+
maxDownloadMB: 50, // per response, so for the tarball
|
|
99
|
+
// the npm registry itself, checked once a day so the page can show the checks still hold
|
|
100
|
+
control: { registry: 'https://registry.npmjs.org', package: 'fhirpath', version: '3.15.0' }
|
|
101
|
+
};
|
|
102
|
+
const NPM_CHECK_CONTROL_MAX_AGE = 24 * 60 * 60 * 1000;
|
|
103
|
+
|
|
104
|
+
// The query string of a request, for logging (the parameters were validated before the handler runs)
|
|
105
|
+
function queryString(req) {
|
|
106
|
+
const i = req.originalUrl.indexOf('?');
|
|
107
|
+
return i < 0 ? '' : req.originalUrl.substring(i + 1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A request for /{id}/{version} with this header gets the tarball, whatever its Accept header
|
|
111
|
+
// says. The HL7 Java PackageClient sends it; any client that downloads from that URL while
|
|
112
|
+
// asking for JSON can send it too.
|
|
113
|
+
const TARBALL_HEADER = 'FHIR-Package-Download';
|
|
114
|
+
|
|
115
|
+
// Each id#version is stored once (see PackageCrawler.checkVersion)
|
|
116
|
+
const UNIQUE_ID_VERSION_INDEX = 'CREATE UNIQUE INDEX SK_PackageVersions_IdVersion ON PackageVersions (Id, Version)';
|
|
117
|
+
|
|
82
118
|
class PackagesModule {
|
|
83
119
|
constructor(stats) {
|
|
84
120
|
this.router = express.Router();
|
|
@@ -610,6 +646,9 @@ class PackagesModule {
|
|
|
610
646
|
content += '<div class="card-body">';
|
|
611
647
|
content += '<p><a href="/packages/search" class="btn btn-primary mb-2">Search Packages</a></p>';
|
|
612
648
|
content += '<p><a href="/packages/log" class="btn btn-secondary mb-2">View Crawler Log</a></p>';
|
|
649
|
+
if (this.npmCheckConfig().enabled) {
|
|
650
|
+
content += '<p><a href="/packages/npm-check" class="btn btn-secondary mb-2">Check an npm Registry</a></p>';
|
|
651
|
+
}
|
|
613
652
|
if (this.config.crawler.enabled) {
|
|
614
653
|
content += '<p><button onclick="triggerCrawl()" class="btn btn-warning mb-2">Manual Crawl</button></p>';
|
|
615
654
|
}
|
|
@@ -708,6 +747,11 @@ class PackagesModule {
|
|
|
708
747
|
)`, (err) => {
|
|
709
748
|
if (err) pckLog.error('Failed to create FeedPages table:', err.message);
|
|
710
749
|
});
|
|
750
|
+
// Fails while the database still holds duplicate id#version rows (from before the
|
|
751
|
+
// crawler checked for them): packages/utilities/dedupe-versions.js removes them
|
|
752
|
+
this.db.run(UNIQUE_ID_VERSION_INDEX.replace('CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX IF NOT EXISTS'), (err) => {
|
|
753
|
+
if (err) pckLog.warn('PackageVersions has duplicate id#version rows, so they are not yet unique; run packages/utilities/dedupe-versions.js: ' + err.message);
|
|
754
|
+
});
|
|
711
755
|
resolve();
|
|
712
756
|
}
|
|
713
757
|
}
|
|
@@ -797,6 +841,7 @@ class PackagesModule {
|
|
|
797
841
|
'CREATE INDEX SK_Packages_Id ON Packages (Id, PackageKey)',
|
|
798
842
|
'CREATE INDEX SK_Packages_Canonical ON Packages (Canonical, PackageKey)',
|
|
799
843
|
'CREATE INDEX SK_PackageVersions_Id ON PackageVersions (Id, Version, PackageVersionKey)',
|
|
844
|
+
UNIQUE_ID_VERSION_INDEX,
|
|
800
845
|
'CREATE INDEX SK_PackageVersions_Canonical ON PackageVersions (Canonical, PackageVersionKey)',
|
|
801
846
|
'CREATE INDEX SK_PackageVersions_PubDate ON PackageVersions (Id, PubDate, PackageVersionKey)',
|
|
802
847
|
'CREATE INDEX SK_PackageVersions_Indexed ON PackageVersions (Indexed, PackageVersionKey)',
|
|
@@ -1051,13 +1096,34 @@ class PackagesModule {
|
|
|
1051
1096
|
}
|
|
1052
1097
|
});
|
|
1053
1098
|
|
|
1099
|
+
// GET /packages/npm-check - the npm registry protocol checks, against any registry
|
|
1100
|
+
const npmCheckLimiter = rateLimit({
|
|
1101
|
+
windowMs: 10 * 60 * 1000,
|
|
1102
|
+
limit: () => this.npmCheckConfig().runsPerWindow,
|
|
1103
|
+
skip: req => !req.query.registry, // only runs count, not the form
|
|
1104
|
+
standardHeaders: true,
|
|
1105
|
+
legacyHeaders: false,
|
|
1106
|
+
message: {error: 'Too many npm registry checks from this address; try again in a few minutes'}
|
|
1107
|
+
});
|
|
1108
|
+
this.router.get('/npm-check', npmCheckLimiter, this.validateQueryParams(NPM_CHECK_PARAMS), async (req, res) => {
|
|
1109
|
+
const start = Date.now();
|
|
1110
|
+
try {
|
|
1111
|
+
await this.serveNpmCheck(req, res);
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
pckLog.error('Error in /packages/npm-check:', error);
|
|
1114
|
+
res.status(500).json({error: 'Internal server error'});
|
|
1115
|
+
} finally {
|
|
1116
|
+
this.stats.countRequest('npm-check', Date.now() - start);
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1054
1120
|
// GET /packages/catalog - Search packages or get updates
|
|
1055
1121
|
this.router.get('/catalog', this.validateQueryParams(searchParams), async (req, res) => {
|
|
1056
1122
|
const start = Date.now();
|
|
1057
1123
|
try {
|
|
1058
1124
|
try {
|
|
1059
1125
|
await this.serveSearch(req, res);
|
|
1060
|
-
pckLog.info("/catalog" +
|
|
1126
|
+
pckLog.info("/catalog?" + queryString(req));
|
|
1061
1127
|
} catch (error) {
|
|
1062
1128
|
pckLog.error('Error in /packages/catalog:', error);
|
|
1063
1129
|
res.status(500).json({error: 'Internal server error'});
|
|
@@ -1073,7 +1139,7 @@ class PackagesModule {
|
|
|
1073
1139
|
try {
|
|
1074
1140
|
try {
|
|
1075
1141
|
await this.serveV1Search(req, res);
|
|
1076
|
-
pckLog.info("/search?" +
|
|
1142
|
+
pckLog.info("/search?" + queryString(req));
|
|
1077
1143
|
} catch (error) {
|
|
1078
1144
|
pckLog.error('Error in /packages/-/v1/search:', error);
|
|
1079
1145
|
res.status(500).json({error: 'Internal server error'});
|
|
@@ -1093,7 +1159,7 @@ class PackagesModule {
|
|
|
1093
1159
|
let days = daysValue || '10';
|
|
1094
1160
|
let date = dateValue || new Date().toISOString().split('T')[0];
|
|
1095
1161
|
await this.serveUpdates(req.secure, res, req, dt, days, date);
|
|
1096
|
-
pckLog.info("/updates?" +
|
|
1162
|
+
pckLog.info("/updates?" + queryString(req));
|
|
1097
1163
|
} catch (error) {
|
|
1098
1164
|
pckLog.error('Error in /packages/updates:', error);
|
|
1099
1165
|
res.status(500).json({error: 'Internal server error'});
|
|
@@ -1221,8 +1287,16 @@ class PackagesModule {
|
|
|
1221
1287
|
try {
|
|
1222
1288
|
try {
|
|
1223
1289
|
const {id, version} = req.params;
|
|
1224
|
-
|
|
1225
|
-
|
|
1290
|
+
// The same URL gives the tarball or (npm) the version's JSON document
|
|
1291
|
+
res.vary('Accept');
|
|
1292
|
+
res.vary(TARBALL_HEADER);
|
|
1293
|
+
if (this.wantsVersionDocument(req)) {
|
|
1294
|
+
await this.serveVersionDocument(req.secure, id, version, req, res);
|
|
1295
|
+
pckLog.info(`/version/${id}/${version}`);
|
|
1296
|
+
} else {
|
|
1297
|
+
await this.serveDownload(req.secure, id, version, res);
|
|
1298
|
+
pckLog.info(`/download/${id}/${version}`);
|
|
1299
|
+
}
|
|
1226
1300
|
} catch (error) {
|
|
1227
1301
|
pckLog.error('Error in /packages/:id/:version:', error);
|
|
1228
1302
|
res.status(500).json({error: 'Internal server error'});
|
|
@@ -1276,7 +1350,7 @@ class PackagesModule {
|
|
|
1276
1350
|
// Don't process routes that are handled elsewhere. These must call next():
|
|
1277
1351
|
// returning without responding leaves the request hanging, which is what
|
|
1278
1352
|
// /status, /stats and /search (registered after this route) used to do.
|
|
1279
|
-
if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates'].includes(id) ||
|
|
1353
|
+
if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates', 'npm-check'].includes(id) ||
|
|
1280
1354
|
id.endsWith('.html') || id === '-') {
|
|
1281
1355
|
next();
|
|
1282
1356
|
return;
|
|
@@ -1645,8 +1719,159 @@ class PackagesModule {
|
|
|
1645
1719
|
});
|
|
1646
1720
|
}
|
|
1647
1721
|
|
|
1722
|
+
npmCheckConfig() {
|
|
1723
|
+
const cfg = this.config?.npmCheck || {};
|
|
1724
|
+
const control = cfg.control === false ? null : {...NPM_CHECK_DEFAULTS.control, ...(cfg.control || {})};
|
|
1725
|
+
return {...NPM_CHECK_DEFAULTS, ...cfg, control};
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// The HTTP client for the checks: the crawler's SSRF-guarded agents, a timeout and a size
|
|
1729
|
+
// cap on every response. Loopback addresses are allowed, so the checks can run against this
|
|
1730
|
+
// server itself; every other non-public address (the server's network, cloud metadata) is not.
|
|
1731
|
+
npmCheckGetter() {
|
|
1732
|
+
const cfg = this.npmCheckConfig();
|
|
1733
|
+
return makeGetter({
|
|
1734
|
+
...this.crawler.guardedAgents({allowLoopback: true}),
|
|
1735
|
+
timeout: cfg.requestTimeout,
|
|
1736
|
+
maxBytes: cfg.maxDownloadMB * 1024 * 1024,
|
|
1737
|
+
userAgent: 'FHIRsmith npm registry check (+https://github.com/HealthIntersections/FHIRsmith)'
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// Runs the checks against the npm registry itself, at most once a day, in the background
|
|
1742
|
+
refreshNpmCheckControl() {
|
|
1743
|
+
const control = this.npmCheckConfig().control;
|
|
1744
|
+
if (!control || this.npmCheckControlRunning) {
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
if (this.npmCheckControlReport && Date.now() - Date.parse(this.npmCheckControlReport.finished) < NPM_CHECK_CONTROL_MAX_AGE) {
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
this.npmCheckControlRunning = true;
|
|
1751
|
+
runNpmCheck({registry: control.registry, pkg: control.package, version: control.version}, this.npmCheckGetter())
|
|
1752
|
+
.then(report => {
|
|
1753
|
+
this.npmCheckControlReport = report;
|
|
1754
|
+
if (report.failed > 0) {
|
|
1755
|
+
pckLog.warn(`npm-check: ${report.failed} checks fail against the control ${control.registry} - they may be out of date`);
|
|
1756
|
+
}
|
|
1757
|
+
})
|
|
1758
|
+
.catch(error => pckLog.error('npm-check control run failed: ' + error.message))
|
|
1759
|
+
.finally(() => {
|
|
1760
|
+
this.npmCheckControlRunning = false;
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
async serveNpmCheck(req, res) {
|
|
1765
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
1766
|
+
const cfg = this.npmCheckConfig();
|
|
1767
|
+
const fail = (status, message) => {
|
|
1768
|
+
if (acceptsHtml) {
|
|
1769
|
+
return this.sendNpmCheckPage(res, status, `<div class="alert alert-danger">${escape(message)}</div>` +
|
|
1770
|
+
npmCheckPage.renderForm({values: req.query, control: cfg.control, controlReport: this.npmCheckControlReport}));
|
|
1771
|
+
}
|
|
1772
|
+
return res.status(status).json({error: message});
|
|
1773
|
+
};
|
|
1774
|
+
|
|
1775
|
+
if (!cfg.enabled) {
|
|
1776
|
+
const message = 'The npm registry check is not enabled on this server';
|
|
1777
|
+
return acceptsHtml ? this.sendNpmCheckPage(res, 404, `<div class="alert alert-info">${escape(message)}</div>`)
|
|
1778
|
+
: res.status(404).json({error: message});
|
|
1779
|
+
}
|
|
1780
|
+
const {registry, package: pkg, version} = req.query;
|
|
1781
|
+
if (!registry) {
|
|
1782
|
+
if (acceptsHtml) {
|
|
1783
|
+
this.refreshNpmCheckControl();
|
|
1784
|
+
return this.sendNpmCheckPage(res, 200, npmCheckPage.renderForm({values: req.query, control: cfg.control, controlReport: this.npmCheckControlReport}));
|
|
1785
|
+
}
|
|
1786
|
+
return res.status(400).json({error: 'Parameter registry is required'});
|
|
1787
|
+
}
|
|
1788
|
+
// validateQueryParams already refuses a repeated parameter (which Express makes an array);
|
|
1789
|
+
// checked again here so this handler doesn't depend on that
|
|
1790
|
+
if (typeof registry !== 'string' || typeof pkg !== 'string' || typeof version !== 'string') {
|
|
1791
|
+
return fail(400, 'The registry, package and version must each be given once');
|
|
1792
|
+
}
|
|
1793
|
+
if (!pkg || !version) {
|
|
1794
|
+
return fail(400, 'Give the package and version to check, as well as the registry');
|
|
1795
|
+
}
|
|
1796
|
+
let url;
|
|
1797
|
+
try {
|
|
1798
|
+
url = new URL(registry);
|
|
1799
|
+
} catch (e) {
|
|
1800
|
+
return fail(400, `The registry URL isn't valid: ${registry}`);
|
|
1801
|
+
}
|
|
1802
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
1803
|
+
return fail(400, 'The registry URL must be http(s), with no user name or password');
|
|
1804
|
+
}
|
|
1805
|
+
if ((this.npmChecksRunning || 0) >= cfg.maxConcurrentRuns) {
|
|
1806
|
+
res.setHeader('Retry-After', '30');
|
|
1807
|
+
return fail(503, 'This server is already running as many npm registry checks as it allows; try again in a minute');
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
this.npmChecksRunning = (this.npmChecksRunning || 0) + 1;
|
|
1811
|
+
let report;
|
|
1812
|
+
try {
|
|
1813
|
+
report = await runNpmCheck({registry, pkg, version}, this.npmCheckGetter());
|
|
1814
|
+
} finally {
|
|
1815
|
+
this.npmChecksRunning--;
|
|
1816
|
+
}
|
|
1817
|
+
pckLog.info(`npm-check ${registry} ${pkg}#${version}: ${report.passed} passed, ${report.failed} failed`);
|
|
1818
|
+
if (acceptsHtml) {
|
|
1819
|
+
this.refreshNpmCheckControl();
|
|
1820
|
+
return this.sendNpmCheckPage(res, 200, npmCheckPage.renderReport({report, control: cfg.control, controlReport: this.npmCheckControlReport}));
|
|
1821
|
+
}
|
|
1822
|
+
res.json(report);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
async sendNpmCheckPage(res, status, content) {
|
|
1826
|
+
if (!htmlServer.hasTemplate('packages')) {
|
|
1827
|
+
htmlServer.loadTemplate('packages', path.join(__dirname, 'packages-template.html'));
|
|
1828
|
+
}
|
|
1829
|
+
const stats = await this.gatherPackageStatistics();
|
|
1830
|
+
res.status(status).setHeader('Content-Type', 'text/html');
|
|
1831
|
+
res.send(htmlServer.renderPage('packages', 'npm Registry Check', content, stats));
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// GET /{id}/{version} is npm's version document when the request asks for JSON, and the
|
|
1835
|
+
// tarball otherwise - which is what FHIR clients have always got there, without asking
|
|
1836
|
+
// for JSON. A request carrying the TARBALL_HEADER always gets the tarball.
|
|
1837
|
+
wantsVersionDocument(req) {
|
|
1838
|
+
if (req.get(TARBALL_HEADER) !== undefined) {
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
return /application\/(json|vnd\.npm\.install-v1\+json)/i.test(req.get('accept') || '');
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
async serveVersionDocument(secure, id, version, req, res) {
|
|
1845
|
+
const packageVersions = await this.getPackageVersions(id);
|
|
1846
|
+
if (packageVersions.length === 0) {
|
|
1847
|
+
res.status(404).json({error: `Package "${id}" not found`});
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
const registry = await this.buildRegistryResponse(id, packageVersions, secure, req);
|
|
1851
|
+
// Resolved as a download is: the exact version, a dist-tag, or the most recently
|
|
1852
|
+
// published pre-release of that version
|
|
1853
|
+
let v = Object.hasOwn(registry.versions, version) ? version
|
|
1854
|
+
: Object.hasOwn(registry['dist-tags'], version) ? registry['dist-tags'][version] : undefined;
|
|
1855
|
+
if (!v) {
|
|
1856
|
+
v = packageVersions.find(pv => pv.Version.startsWith(`${version}-`))?.Version;
|
|
1857
|
+
}
|
|
1858
|
+
if (!v) {
|
|
1859
|
+
res.status(404).json({error: `Version "${version}" of package "${id}" not found`});
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
res.json(registry.versions[v]);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1648
1865
|
async serveDownload(secure, id, version, res) {
|
|
1649
1866
|
try {
|
|
1867
|
+
// A dist-tag in place of a version (npm). The only tag is latest.
|
|
1868
|
+
if (version === 'latest') {
|
|
1869
|
+
const versions = await this.getPackageVersions(id);
|
|
1870
|
+
if (versions.length > 0) {
|
|
1871
|
+
version = versions[0].Version; // as dist-tags.latest: the most recently published
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1650
1875
|
// First try exact version match
|
|
1651
1876
|
let packageData = await this.findPackageVersion(id, version, true);
|
|
1652
1877
|
|
|
@@ -1951,6 +2176,13 @@ class PackagesModule {
|
|
|
1951
2176
|
registry.versions[pv.Version] = versionObj;
|
|
1952
2177
|
}
|
|
1953
2178
|
|
|
2179
|
+
// Publication times, as npm gives them: one per version, plus created and modified
|
|
2180
|
+
const dates = packageVersions.map(pv => new Date(pv.PubDate).toISOString()).sort();
|
|
2181
|
+
registry.time = {created: dates[0], modified: dates[dates.length - 1]};
|
|
2182
|
+
for (const pv of packageVersions) {
|
|
2183
|
+
registry.time[pv.Version] = new Date(pv.PubDate).toISOString();
|
|
2184
|
+
}
|
|
2185
|
+
|
|
1954
2186
|
// Set latest version and description at package level
|
|
1955
2187
|
registry['dist-tags'].latest = latestVersion;
|
|
1956
2188
|
if (description) {
|
|
@@ -2118,7 +2350,7 @@ class PackagesModule {
|
|
|
2118
2350
|
const q = req.query;
|
|
2119
2351
|
const secure = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
2120
2352
|
try {
|
|
2121
|
-
|
|
2353
|
+
let results = await this.searchPackages({
|
|
2122
2354
|
name: q.name,
|
|
2123
2355
|
dependson: q.dependson,
|
|
2124
2356
|
canonicalPkg: q.pkgcanonical,
|
|
@@ -2129,6 +2361,16 @@ class PackagesModule {
|
|
|
2129
2361
|
sort: q.sort
|
|
2130
2362
|
}, req, secure);
|
|
2131
2363
|
|
|
2364
|
+
// npm puts the package whose name is the search text first. Without a sort, the
|
|
2365
|
+
// rest stay in publication order.
|
|
2366
|
+
if (q.text && !q.sort) {
|
|
2367
|
+
const name = q.text.trim().toLowerCase();
|
|
2368
|
+
const exact = results.filter(r => r.name.toLowerCase() === name);
|
|
2369
|
+
if (exact.length > 0) {
|
|
2370
|
+
results = [...exact, ...results.filter(r => r.name.toLowerCase() !== name)];
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2132
2374
|
const from = parseInt(q.from, 10) || 0;
|
|
2133
2375
|
const page = q.size
|
|
2134
2376
|
? results.slice(from, from + Math.min(parseInt(q.size, 10), V1_SEARCH_MAX_SIZE))
|
|
@@ -3055,7 +3297,7 @@ class PackagesModule {
|
|
|
3055
3297
|
}
|
|
3056
3298
|
}
|
|
3057
3299
|
|
|
3058
|
-
PackagesModule.QUERY_PARAMS = { search: SEARCH_PARAMS, v1Search: V1_SEARCH_PARAMS, updates: UPDATES_PARAMS, broken: BROKEN_PARAMS };
|
|
3300
|
+
PackagesModule.QUERY_PARAMS = { search: SEARCH_PARAMS, v1Search: V1_SEARCH_PARAMS, updates: UPDATES_PARAMS, broken: BROKEN_PARAMS, npmCheck: NPM_CHECK_PARAMS };
|
|
3059
3301
|
PackagesModule.FHIR_RELEASE_VERSIONS = FHIR_RELEASE_VERSIONS;
|
|
3060
3302
|
PackagesModule.PATH_PARAMS = {
|
|
3061
3303
|
id: { maxLength: PACKAGE_ID_MAX_LENGTH, pattern: PACKAGE_ID_PATTERN },
|
package/packages/readme.md
CHANGED
|
@@ -31,9 +31,10 @@ In summary:
|
|
|
31
31
|
| `GET /packages/catalog` | Search packages: `name`, `canonical`, `pkgcanonical`, `fhirversion`, `dependency`, `dependson`, `sort` |
|
|
32
32
|
| `GET /packages/-/v1/search` | npm search API: `text`, `size`, `from`, plus the `/catalog` parameters |
|
|
33
33
|
| `GET /packages/{id}` | npm package document: all versions, dependencies, tarball URLs |
|
|
34
|
-
| `GET /packages/{id}/{version}` | Download a package tarball |
|
|
34
|
+
| `GET /packages/{id}/{version}` | Download a package tarball; or, when the request's `Accept` asks for JSON, npm's document for that version. A `FHIR-Package-Download` header always gets the tarball (the HL7 Java tools send it) |
|
|
35
35
|
| `GET /packages/updates` | Versions published since a date |
|
|
36
36
|
| `GET /packages/broken` | Package versions whose dependencies this registry doesn't hold |
|
|
37
|
+
| `GET /packages/npm-check` | Check any registry against the npm registry protocol (a form, in a browser) |
|
|
37
38
|
|
|
38
39
|
Most endpoints return an HTML page instead of JSON when the request's `Accept`
|
|
39
40
|
header contains `text/html`. The JSON is the contract; the HTML is for people.
|
|
@@ -93,7 +94,33 @@ In the server config, under `modules.packages`:
|
|
|
93
94
|
| `crawler.schedule` | Cron expression for crawls. |
|
|
94
95
|
| `masterUrl` | The list of feeds to crawl. Defaults to `https://fhir.github.io/ig-registry/package-feeds.json`. |
|
|
95
96
|
| `localFeedDirs` | Optional. Directories feeds may be read from as local files (for testing). |
|
|
96
|
-
| `allowPrivateAddresses` | Optional, for testing only. Lets the crawler fetch from private and loopback addresses, which
|
|
97
|
+
| `allowPrivateAddresses` | Optional, for testing only. Lets the crawler and `/npm-check` fetch from private and loopback addresses, which they otherwise refuse (SSRF protection). |
|
|
98
|
+
| `npmCheck.enabled` | `/packages/npm-check`. On unless set to `false`. |
|
|
99
|
+
| `npmCheck.maxConcurrentRuns` | Runs at once, across all users; more get a 503. Default 2. |
|
|
100
|
+
| `npmCheck.runsPerWindow` | Runs per client address per 10 minutes; more get a 429. Default 10. |
|
|
101
|
+
| `npmCheck.requestTimeout` | Milliseconds allowed for each request a run makes. Default 30000. |
|
|
102
|
+
| `npmCheck.maxDownloadMB` | Largest response a run will read (so, the tarball). Default 50. |
|
|
103
|
+
| `npmCheck.control` | The registry, package and version the checks are verified against daily; default `registry.npmjs.org`, `fhirpath` 3.15.0. `false` turns that off. |
|
|
104
|
+
|
|
105
|
+
## Checking npm compatibility
|
|
106
|
+
|
|
107
|
+
This server aims to be an npm registry, carrying FHIR fields as well. `npm-check.js`
|
|
108
|
+
holds checks of the npm registry protocol, written from the way npm clients use it:
|
|
109
|
+
search by name, the package document, one version's document, and the tarball. They
|
|
110
|
+
are used three ways:
|
|
111
|
+
|
|
112
|
+
* `/packages/npm-check` runs them against any registry and shows a report. Its requests
|
|
113
|
+
go through the crawler's SSRF guard, with a timeout and a size cap, and runs are rate
|
|
114
|
+
limited. Unlike the crawler, it may use loopback addresses (`localhost`, `127.0.0.1`,
|
|
115
|
+
`::1`), so it can check this server itself; other non-public addresses are refused.
|
|
116
|
+
* `tests/packages/npm-registry.test.js` runs them against registry.npmjs.org, which must
|
|
117
|
+
pass every check: if npm changes its behaviour, that test is how we find out. Set
|
|
118
|
+
`NPM_REGISTRY_TARGETS` to run them against other registries too.
|
|
119
|
+
* `tests/packages/npm-registry-local.test.js` runs them against this module, started
|
|
120
|
+
locally with known packages.
|
|
121
|
+
|
|
122
|
+
`/packages/npm-check` also runs them against registry.npmjs.org once a day, and says on
|
|
123
|
+
the page if any fail there.
|
|
97
124
|
|
|
98
125
|
## Crawling
|
|
99
126
|
|
|
@@ -108,6 +135,16 @@ stores it.
|
|
|
108
135
|
crawl; older pages are read once and remembered in the `FeedPages` table.
|
|
109
136
|
* A feed that rate-limits (HTTP 429) is abandoned for that crawl and retried next
|
|
110
137
|
time.
|
|
138
|
+
* Each id#version is stored once. Simplifier re-publishes packages that other feeds
|
|
139
|
+
publish directly, so its copy is a secondary echo: a version we already hold is
|
|
140
|
+
never fetched from Simplifier, and a version we only hold from Simplifier is
|
|
141
|
+
replaced when it arrives directly. A second direct copy of a version we already
|
|
142
|
+
hold, from a different GUID, is logged as an error and ignored. This is checked
|
|
143
|
+
against the feed's title before downloading, and against the tarball's own
|
|
144
|
+
package.json before storing. The database enforces it with a unique index on
|
|
145
|
+
`(Id, Version)`.
|
|
146
|
+
* The mirror copy of a tarball is written only after the package has passed
|
|
147
|
+
validation and been stored.
|
|
111
148
|
|
|
112
149
|
`/packages/log` shows what the last crawl did, feed by feed and item by item.
|
|
113
150
|
|
|
@@ -118,3 +155,19 @@ metadata and the tarball itself. `Packages` holds one row per package id, with i
|
|
|
118
155
|
current version and download count. `PackageDependencies` (stored as `id@version`),
|
|
119
156
|
`PackageFHIRVersions` and `PackageURLs` (the canonical URLs of the resources in each
|
|
120
157
|
version) support search.
|
|
158
|
+
|
|
159
|
+
### Repairing an older database
|
|
160
|
+
|
|
161
|
+
Databases built before each id#version was stored once can hold duplicate rows, and
|
|
162
|
+
mirror files that don't match the stored hash. `dedupe-versions.js` repairs both:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
node packages/dedupe-versions.js -db <packages.db> -mirror <mirror folder> # report only
|
|
166
|
+
node packages/dedupe-versions.js -db <packages.db> -mirror <mirror folder> -apply
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
It keeps one row per id#version by the crawler's rule (direct beats Simplifier,
|
|
170
|
+
otherwise the first stored), lists any id#version with two different direct copies
|
|
171
|
+
for manual cleanup, creates the unique index once no duplicates remain, and rewrites
|
|
172
|
+
any mirror file whose content doesn't match its row's hash, from the database. Back up
|
|
173
|
+
the database first. Until the index exists, the server logs a warning at startup.
|