diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3e8ebe8f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +# Dependabot for FHIRsmith. +# +# Email notification of vulnerable dependencies does NOT come from this file - it comes +# from Dependabot alerts, which are switched on in the repository settings +# (Settings > Code security > Dependabot alerts, and Dependabot security updates), and +# delivered according to each person's GitHub notification settings +# (Settings > Notifications > Dependabot alerts > Email). +# +# What this file does: when an alert is raised, Dependabot opens one pull request that +# moves the affected packages to fixed versions. Routine version-bump PRs are turned off +# (open-pull-requests-limit: 0) - only security fixes produce PRs. + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 0 + groups: + npm-security: + applies-to: security-updates + patterns: + - "*" + commit-message: + prefix: "deps" diff --git a/CHANGELOG.md b/CHANGELOG.md index b1263a1c..b5c597d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,41 @@ All notable changes to Health Intersections FHIRsmith will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.14.2] - +## [0.14.2] - 2026-10-06 +### Security + +- Dependency updates for published advisories, including axios (several high severity), brace-expansion (denial of service), and http-cache-semantics and @tootallnate/once (removed with the old sqlite3 build chain) + +### Added + +- OpenAPI descriptions for the package server (/packages), the terminology registry (/tx-reg), the TestReport module (/testing) and the R5 terminology endpoints: each serves `openapi.json`, `openapi.yaml` and an HTML reference at `openapi` (JSON for non-browser clients), linked from the module's pages and advertised in an RFC 8631 `Link` header on every response. The FHIR resource schemas are generated from the FHIR definitions, constrained to what each module accepts +- npm audit self-check: once a day the server checks its installed packages (and FHIRsmith itself) against the npm advisory database - report only, nothing is changed. Findings show as a banner on the home page, in full on the dashboard, and in the log. `npmAudit.enabled` = false turns it off (e.g. for servers with no internet access) +- Registry: new Software page (/tx-reg/software) listing each registered server's software and version, and for FHIRsmith servers, the release date, its age, and how many releases behind it is. The crawler now records `CapabilityStatement.software.version` + +### Changed + +- Terminology server: contained resources are supported only for ValueSets that contain ValueSets (which `compose.include.valueSet` can import by `#id`). Any other contained resource is rejected (CONTAINED_RESOURCE_NOT_SUPPORTED) wherever the resource comes from; when loading a package, the offending resource is skipped rather than stopping the load +- $validate-code: the `inactive` and `status` output parameters describe the code being returned. In a CodeableConcept where a different coding is inactive, that coding still gets its warning, but the parameters are not set for the returned code +- Registry discovery API follows the tx ecosystem IG: rows carry `fhirVersion` and the IG's security flags (`open`, `token`, ...) alongside the existing security string; candidate lists are only given when `url` is supplied; R-codes (R4, R4B, R5, ...) map to their release versions; and the resolve fallback that returned servers authoritative for a code system but not hosting it has been removed +- The TerminologyCapabilities statement no longer lists expansion parameters the server doesn't act on (`limitedExpansion`, `_incomplete`, `incomplete-ok` and others); `limitedExpansion`/`incomplete-ok` are no longer read, and no longer part of the expansion cache key (they never had any effect) +- TestReport module: a TestReport with contained resources is refused +- Packages and registry page footers say when the crawler last updated the data ("last updated 35 minutes ago"), or "not yet updated" before the first crawl - they used to show a raw `[%crawler-date%]` placeholder +- Packages crawler log: the start time is shown relative ("35 minutes ago") and the duration in seconds; the end time is dropped +- Dependencies: sqlite3 6 (connect-sqlite3 now uses the same sqlite3, which drops the old node-gyp/make-fetch-happen chain), and updates for axios, brace-expansion, moment, ip-address, csv-parse and uuid. The PR build now fails only on high/critical advisories in what ships (`npm audit --omit=dev`) + +### Fixed + +- $expand: RxNorm expansions that asked for a concept's children failed with `SQLITE_MISUSE`; an over-limit expansion of the whole of RxNorm is now refused before it is built +- $expand: a value set with neither a compose nor an expansion gets a clear error (VALUESET_NO_COMPOSE) +- $validate-code: in a CodeableConcept, the inactive warning names the coding that is actually inactive (it used to name the first coding), and a later active coding no longer hides the warning from an earlier inactive one +- Packages: `/status`, `/stats` and `/search` hung instead of answering (the `/:id` route swallowed them) +- Packages: dependency searches accept `id#version` and `id|version` (as sent by the Java PackageClient) as well as `id@version`; npm search (`/-/v1/search`) honours `text`, `size` and `from`, and accepts the other npm and PackageClient parameters +- XIG: the version shown on the pages + +### Tx Conformance Statement + +FHIRsmith passed all 3585 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v6.10.4) ## [0.14.1] - 2026-09-29 diff --git a/config-template.json b/config-template.json index 8b404282..24cdd89c 100644 --- a/config-template.json +++ b/config-template.json @@ -18,6 +18,15 @@ // how often the counters are written out "intervalMinutes": 10 }, + // Once a day the server checks its installed npm packages against the npm advisory + // database (what `npm audit` does - report only, nothing is changed). Problems show on + // the home page and the dashboard, and in the log. Needs outbound access to + // registry.npmjs.org. The whole block is optional. + "npmAudit": { + // set false to turn the check off (e.g. a server with no internet access) + "enabled": true, + "intervalHours": 24 + }, "modules": { "shl": { "enabled": false, diff --git a/library/html-server.js b/library/html-server.js index 358ab2d1..3dba94a3 100644 --- a/library/html-server.js +++ b/library/html-server.js @@ -75,6 +75,8 @@ class HtmlServer { // [%ver%] is the FHIRsmith version in every template (it follows the FHIRsmith link) .replace(/\[%ver%\]/g, escape(packageJson.version)) .replace(/\[%download-date%\]/g, escape(renderOptions.downloadDate)) + // "last updated 35 minutes ago" / "not yet updated", for the crawler-driven modules + .replace(/\[%crawler-status%\]/g, escape(renderOptions.crawlerStatus || 'not yet updated')) .replace(/\[%total-resources%\]/g, escape(renderOptions.totalResources.toLocaleString())) .replace(/\[%total-packages%\]/g, escape(renderOptions.totalPackages.toLocaleString())) .replace(/\[%endpoint-path%\]/g, escape(renderOptions.endpointpath)) diff --git a/library/npm-audit.js b/library/npm-audit.js new file mode 100644 index 00000000..8b5826a2 --- /dev/null +++ b/library/npm-audit.js @@ -0,0 +1,265 @@ +// library/npm-audit.js +// Periodic self-check of this server's installed npm packages against the npm advisory +// database - the same lookup `npm audit` does, made directly so it needs neither the npm +// binary nor a particular working directory. Reports only; it never changes anything. +// +// The package list comes from node_modules/.package-lock.json (what is actually +// installed), falling back to package-lock.json. Dev dependencies are left out, and +// fhirsmith itself is included, so an advisory published against fhirsmith shows up too. +// +// Config (all optional), top level of config.json: +// "npmAudit": { "enabled": true, "intervalHours": 24 } + +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); +const escape = require('escape-html'); + +const DEFAULT_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'; +const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info']; +const HOUR_MS = 60 * 60 * 1000; +const FIRST_RUN_DELAY_MS = 60 * 1000; // let startup finish first + +function describeAgo(time, now = Date.now()) { + const mins = Math.max(0, Math.floor((now - time) / 60000)); + if (mins < 1) { + return 'just now'; + } + if (mins < 60) { + return `${mins} minute${mins === 1 ? '' : 's'} ago`; + } + const hours = Math.floor(mins / 60); + if (hours < 48) { + return `${hours} hour${hours === 1 ? '' : 's'} ago`; + } + return `${Math.floor(hours / 24)} days ago`; +} + +class NpmAudit { + constructor(config = {}, logger = null, appDir = path.join(__dirname, '..'), stats = null) { + this.enabled = config.enabled !== false; + this.intervalHours = config.intervalHours > 0 ? config.intervalHours : 24; + this.url = config.url || DEFAULT_URL; + this.timeout = config.timeout || 60000; + this.logger = logger; + this.appDir = appDir; + this.stats = stats; + this.timers = []; + this.running = false; + // the outcome of the last completed check + this.checkedAt = null; // Date of the last successful check + this.packageCount = 0; + this.findings = []; // [{name, installed: [versions], id, severity, title, url, range}] + this.lastError = null; // message from the last attempt, if it failed + this.lastAttempt = null; + } + + /** + * name -> [installed versions], production packages only, plus this package itself + */ + collectPackages() { + let lock = null; + for (const file of [path.join(this.appDir, 'node_modules', '.package-lock.json'), path.join(this.appDir, 'package-lock.json')]) { + try { + lock = JSON.parse(fs.readFileSync(file, 'utf8')); + break; + } catch (e) { + // try the next one + } + } + if (!lock || !lock.packages) { + throw new Error('No package-lock.json found to audit'); + } + const found = new Map(); + const add = (name, version) => { + if (!found.has(name)) { + found.set(name, new Set()); + } + found.get(name).add(version); + }; + for (const [key, info] of Object.entries(lock.packages)) { + if (!key || !info || info.dev || info.link || !info.version) { + continue; + } + const marker = 'node_modules/'; + const name = info.name || key.substring(key.lastIndexOf(marker) + marker.length); + add(name, info.version); + } + try { + const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8')); + if (pkg.name && pkg.version) { + add(pkg.name, pkg.version); + } + } catch (e) { + // no package.json - nothing to add + } + const result = {}; + for (const [name, versions] of found) { + result[name] = [...versions].sort(); + } + return result; + } + + async run() { + if (this.running) { + return; + } + this.running = true; + this.lastAttempt = new Date(); + if (this.stats) { + this.stats.task('npm audit', 'Checking'); + } + try { + const packages = this.collectPackages(); + const response = await axios.post(this.url, packages, { + timeout: this.timeout, + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } + }); + const data = response.data && typeof response.data === 'object' ? response.data : {}; + const findings = []; + for (const [name, advisories] of Object.entries(data)) { + for (const a of Array.isArray(advisories) ? advisories : []) { + findings.push({ + name, + installed: packages[name] || [], + id: a.id, + severity: SEVERITIES.includes(a.severity) ? a.severity : 'info', + title: a.title || '', + url: a.url || '', + range: a.vulnerable_versions || '' + }); + } + } + findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || a.name.localeCompare(b.name)); + this.findings = findings; + this.packageCount = Object.keys(packages).length; + this.checkedAt = new Date(); + this.lastError = null; + const summary = this.summary(); + if (findings.length > 0) { + if (this.logger) { + this.logger.warn(`npm audit: ${summary} in ${this.packageCount} packages: ` + + findings.map(f => `${f.name} (${f.severity}) ${f.url}`).join('; ')); + } + if (this.stats) { + this.stats.taskError('npm audit', summary); + } + } else { + if (this.logger) { + this.logger.info(`npm audit: no known vulnerabilities in ${this.packageCount} packages`); + } + if (this.stats) { + this.stats.taskDone('npm audit', `No known vulnerabilities (${this.packageCount} packages)`); + } + } + } catch (error) { + this.lastError = error.message; + if (this.logger) { + this.logger.warn('npm audit could not be run: ' + error.message); + } + if (this.stats) { + this.stats.taskError('npm audit', 'Could not check: ' + error.message); + } + } finally { + this.running = false; + } + } + + start() { + if (!this.enabled) { + return; + } + if (this.stats) { + this.stats.addTask('npm audit', `${this.intervalHours} hr`); + } + // run() handles its own errors, so the promise never rejects + const first = setTimeout(() => void this.run(), FIRST_RUN_DELAY_MS); + const repeat = setInterval(() => void this.run(), this.intervalHours * HOUR_MS); + for (const t of [first, repeat]) { + if (t.unref) { + t.unref(); + } + this.timers.push(t); + } + } + + stop() { + for (const t of this.timers) { + clearTimeout(t); + clearInterval(t); + } + this.timers = []; + } + + counts() { + const counts = {}; + for (const f of this.findings) { + counts[f.severity] = (counts[f.severity] || 0) + 1; + } + return counts; + } + + // "3 known vulnerabilities (1 critical, 2 high)" + summary() { + const n = this.findings.length; + if (n === 0) { + return 'no known vulnerabilities'; + } + const counts = this.counts(); + const parts = SEVERITIES.filter(s => counts[s]).map(s => `${counts[s]} ${s}`); + return `${n} known vulnerabilit${n === 1 ? 'y' : 'ies'} (${parts.join(', ')})`; + } + + isSerious() { + return this.findings.some(f => f.severity === 'critical' || f.severity === 'high'); + } + + /** + * Home page: nothing unless there is something to report + */ + renderBanner(now = Date.now()) { + if (!this.enabled || !this.checkedAt || this.findings.length === 0) { + return ''; + } + const cls = this.isSerious() ? 'alert-danger' : 'alert-warning'; + return `
npm audit: disabled (npmAudit.enabled = false)
npm audit: '; + html += this.lastError ? `could not be run: ${escape(this.lastError)}` : 'not run yet'; + return html + '
'; + } + const colour = this.findings.length === 0 ? '#070' : (this.isSerious() ? '#b00' : '#b60'); + html += `npm audit: ${escape(this.summary())}` + + ` in ${this.packageCount} packages, checked ${escape(describeAgo(this.checkedAt.getTime(), now))}`; + if (this.lastError) { + html += ` (the latest check failed: ${escape(this.lastError)})`; + } + html += '
'; + if (this.findings.length > 0) { + html += '| Severity | Package | Installed | Vulnerable | Advisory |
|---|---|---|---|---|
| ${escape(f.severity)} | ${escape(f.name)} | ${escape(f.installed.join(', '))} | ` + + `${escape(f.range)} | ${link} |
FHIRsmith [%ver%] © HealthIntersections.com.au 2023+ |
- Package Registry last updated as of [%crawler-date%] | [%total-packages%] packages |
+ Package Registry [%crawler-status%] | [%total-packages%] packages |
([%ms%] ms)
[%sponsorMessage%]
diff --git a/packages/packages.js b/packages/packages.js
index 322a4609..002def94 100644
--- a/packages/packages.js
+++ b/packages/packages.js
@@ -14,7 +14,7 @@ const htmlServer = require('../library/html-server');
const folders = require('../library/folder-setup');
const escape = require('escape-html');
const Logger = require('../library/logger');
-const {validateParameter} = require("../library/utilities");
+const {validateParameter, Utilities} = require("../library/utilities");
const {describeCron} = require("../library/cron-utilities");
const {tokenMatches, tokenConfigured} = require("../library/request-token");
const pckLog = Logger.getInstance().child({ module: 'packages' });
@@ -481,8 +481,11 @@ class PackagesModule {
// Get counts from database
const tableCounts = await this.getDatabaseTableCounts();
+ // the page footer: the last crawl this run, else when the database was last written
+ const updated = this.lastRunTime || dbAge.lastModified;
return {
downloadDate: downloadDate,
+ crawlerStatus: updated ? `last updated ${Utilities.describeAgo(updated)}` : 'not yet updated',
totalResources: 0, // Packages don't track individual resources
totalPackages: tableCounts.packages || 0,
totalVersions: tableCounts.packageVersions || 0,
diff --git a/registry/api.js b/registry/api.js
index c937486c..4ba83fa7 100644
--- a/registry/api.js
+++ b/registry/api.js
@@ -2,6 +2,7 @@
const { ServerRegistryUtilities } = require('./model');
const escape = require('escape-html');
+const { Utilities } = require('../library/utilities');
const RELEASE_VERSIONS = {
R2: '1.0',
@@ -286,6 +287,10 @@ class RegistryAPI {
return {
lastRun: data.lastRun,
+ // the page footer: nothing to report until the first crawl has finished
+ crawlerStatus: data.lastRun ?
+ `last updated ${Utilities.describeAgo(data.lastRun)}, ${totalServers} server${totalServers === 1 ? '' : 's'}` :
+ 'not yet updated',
outcome: data.outcome,
registryCount: data.registries.length,
serverCount: totalServers,
diff --git a/registry/crawler.js b/registry/crawler.js
index 846ee3e5..c2ec2cd1 100644
--- a/registry/crawler.js
+++ b/registry/crawler.js
@@ -333,11 +333,33 @@ class RegistryCrawler {
this.addLogEntry('error', `Server ${version.address}: Error after ${elapsed}ms: ${error.message}`);
version.error = error.message;
version.lastTat = `${elapsed}ms`;
+ this.carryForwardSoftware(version);
}
return version;
}
+ /**
+ * A server that can't be reached this time is still running whatever it was running
+ * last time we saw it - keep that, so the software page doesn't lose track of it
+ */
+ carryForwardSoftware(version) {
+ if (version.software && version.software !== 'unknown') {
+ return;
+ }
+ for (const registry of (this.currentData && this.currentData.registries) || []) {
+ for (const server of registry.servers || []) {
+ for (const prev of server.versions || []) {
+ if (prev.address === version.address && prev.software) {
+ version.software = prev.software;
+ version.softwareVersion = prev.softwareVersion || '';
+ return;
+ }
+ }
+ }
+ }
+ }
+
/**
* Process an R3 server
*/
@@ -348,6 +370,7 @@ class RegistryCrawler {
version.version = capability.fhirVersion || '3.0.2';
version.software = capability.software ? capability.software.name : "unknown";
+ version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
// Get terminology capabilities (R3 uses Parameters resource)
try {
@@ -392,6 +415,7 @@ class RegistryCrawler {
version.version = capability.fhirVersion || defVersion;
version.software = capability.software ? capability.software.name : "unknown";
+ version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
let set = new Set();
diff --git a/registry/fhirsmith-releases.js b/registry/fhirsmith-releases.js
new file mode 100644
index 00000000..bbd470ab
--- /dev/null
+++ b/registry/fhirsmith-releases.js
@@ -0,0 +1,170 @@
+// registry/fhirsmith-releases.js
+// The list of FHIRsmith releases and their dates, so the registry can say how old the
+// FHIRsmith version a registered server reports is.
+//
+// The list comes from the GitHub releases API, refreshed with each crawl. Until that
+// succeeds (or if it can't be reached), the dated headings in this server's own
+// CHANGELOG.md are used instead - those only go up to this server's own version.
+
+const fs = require('fs');
+const path = require('path');
+const axios = require('axios');
+
+const DEFAULT_RELEASES_URL = 'https://api.github.com/repos/HealthIntersections/FHIRsmith/releases';
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+// "v0.14.1" -> [0, 14, 1]; anything that isn't n.n.n (optionally with a -suffix) -> null
+function parseVersion(v) {
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim());
+ if (!m) {
+ return null;
+ }
+ return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], suffix: m[4] || '' };
+}
+
+function compareParts(a, b) {
+ for (let i = 0; i < 3; i++) {
+ if (a[i] !== b[i]) {
+ return a[i] - b[i];
+ }
+ }
+ return 0;
+}
+
+function isFhirsmith(softwareName) {
+ return /fhirsmith/i.test(softwareName || '');
+}
+
+class FhirsmithReleases {
+ constructor(config = {}, logger = null) {
+ this.url = config.releasesUrl || DEFAULT_RELEASES_URL;
+ this.timeout = config.timeout || 30000;
+ this.userAgent = config.userAgent || 'FHIRRegistryServer/1.0';
+ this.logger = logger;
+ this.releases = []; // [{version: '0.14.1', parts: [0,14,1], date: Date}], newest first
+ this.source = 'none';
+ this.lastRefresh = null;
+ }
+
+ setReleases(list, source) {
+ const seen = new Set();
+ const releases = [];
+ for (const r of list) {
+ const pv = parseVersion(r.version);
+ const date = r.date instanceof Date ? r.date : new Date(r.date);
+ // a release is n.n.n - pre-release suffixes are not tracked
+ if (!pv || pv.suffix || isNaN(date.getTime())) {
+ continue;
+ }
+ const key = pv.parts.join('.');
+ if (!seen.has(key)) {
+ seen.add(key);
+ releases.push({ version: key, parts: pv.parts, date });
+ }
+ }
+ releases.sort((a, b) => compareParts(b.parts, a.parts));
+ this.releases = releases;
+ this.source = source;
+ }
+
+ /**
+ * Read the dated release headings from a CHANGELOG.md - "## [v0.13.4] - 2026-09-17"
+ */
+ loadFromChangelog(changelogPath = path.join(__dirname, '..', 'CHANGELOG.md')) {
+ try {
+ const text = fs.readFileSync(changelogPath, 'utf8');
+ const list = [];
+ const re = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm;
+ let m;
+ while ((m = re.exec(text)) !== null) {
+ list.push({ version: m[1], date: new Date(m[2] + 'T00:00:00Z') });
+ }
+ this.setReleases(list, 'CHANGELOG.md');
+ } catch (error) {
+ if (this.logger) {
+ this.logger.warn('Could not read FHIRsmith release dates from CHANGELOG.md: ' + error.message);
+ }
+ }
+ }
+
+ /**
+ * Fetch the release list from GitHub. On failure the current list is kept.
+ */
+ async refresh() {
+ try {
+ const list = [];
+ for (let page = 1; page <= 10; page++) {
+ const response = await axios.get(`${this.url}?per_page=100&page=${page}`, {
+ timeout: this.timeout,
+ headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': this.userAgent }
+ });
+ const batch = Array.isArray(response.data) ? response.data : [];
+ for (const r of batch) {
+ if (!r.draft && !r.prerelease && r.tag_name && r.published_at) {
+ list.push({ version: r.tag_name, date: r.published_at });
+ }
+ }
+ if (batch.length < 100) {
+ break;
+ }
+ }
+ if (list.length > 0) {
+ this.setReleases(list, 'GitHub');
+ this.lastRefresh = new Date();
+ }
+ } catch (error) {
+ if (this.logger) {
+ this.logger.warn('Could not fetch FHIRsmith releases from GitHub: ' + error.message);
+ }
+ }
+ }
+
+ latest() {
+ return this.releases.length > 0 ? this.releases[0] : null;
+ }
+
+ /**
+ * How a reported FHIRsmith version stands against the releases:
+ * status: 'current' | 'outdated' | 'dev' (a build between/after releases) | 'unknown'
+ * release: the release it is (or, for 'dev', the most recent release before it)
+ * ageDays: days since that release came out ('current'/'outdated' only)
+ * behind: how many releases have come out since
+ */
+ describe(reportedVersion, now = Date.now()) {
+ const pv = parseVersion(reportedVersion);
+ if (!pv || this.releases.length === 0) {
+ return { status: 'unknown' };
+ }
+ const behind = this.releases.filter(r => compareParts(r.parts, pv.parts) > 0).length;
+ const exact = !pv.suffix ? this.releases.find(r => compareParts(r.parts, pv.parts) === 0) : null;
+ if (exact) {
+ return {
+ status: behind === 0 ? 'current' : 'outdated',
+ release: exact,
+ ageDays: Math.max(0, Math.floor((now - exact.date.getTime()) / DAY_MS)),
+ behind
+ };
+ }
+ // a snapshot (0.14.2-snapshot), or a version we have no release for
+ const base = this.releases.find(r => compareParts(r.parts, pv.parts) < 0) || null;
+ return { status: 'dev', release: base, behind };
+ }
+}
+
+function describeAge(days) {
+ if (days < 1) {
+ return 'today';
+ }
+ if (days < 14) {
+ return `${days} day${days === 1 ? '' : 's'}`;
+ }
+ if (days < 60) {
+ return `${Math.floor(days / 7)} weeks`;
+ }
+ if (days < 730) {
+ return `${Math.floor(days / 30.44)} months`;
+ }
+ return `${(days / 365.25).toFixed(1)} years`;
+}
+
+module.exports = { FhirsmithReleases, isFhirsmith, parseVersion, describeAge };
diff --git a/registry/model.js b/registry/model.js
index d26441fc..d06d9531 100644
--- a/registry/model.js
+++ b/registry/model.js
@@ -11,6 +11,7 @@ class ServerVersionInformation {
this.lastSuccess = null; // Date object
this.lastTat = '';
this.software = ''; // what software is running
+ this.softwareVersion = ''; // CapabilityStatement.software.version, if the server reports one
this.codeSystems = []; // Array of strings (sorted, unique)
this.valueSets = []; // Array of strings (sorted, unique)
}
@@ -58,6 +59,7 @@ class ServerVersionInformation {
lastTat: this.lastTat,
terminologies: this.codeSystems,
software: this.software,
+ 'software-version': this.softwareVersion,
valuesets: this.valueSets
};
}
@@ -71,6 +73,7 @@ class ServerVersionInformation {
instance.lastSuccess = json['last-success'] ? new Date(json['last-success']) : null;
instance.lastTat = json.lastTat || '';
instance.software = json.software;
+ instance.softwareVersion = json['software-version'] || '';
instance.codeSystems = json.terminologies || [];
instance.valueSets = json.valuesets || [];
return instance;
diff --git a/registry/registry-template.html b/registry/registry-template.html
index 8e0e7c72..3bffc017 100644
--- a/registry/registry-template.html
+++ b/registry/registry-template.html
@@ -60,6 +60,7 @@
Server Home |
Registry Home |
Resolve |
+ Software |
Crawler Log |
API
@@ -94,7 +95,7 @@
FHIRsmith [%ver%] © HealthIntersections.com.au 2023+ |
- Terminology Registry last updated as of [%crawler-date%] | [%total-packages%] packages |
+ Terminology Registry [%crawler-status%] |
([%ms%] ms)
[%sponsorMessage%]
diff --git a/registry/registry.js b/registry/registry.js
index 3c2bf64c..04a7982e 100644
--- a/registry/registry.js
+++ b/registry/registry.js
@@ -10,6 +10,7 @@ const regLog = Logger.getInstance().child({ module: 'registry' });
const folders = require('../library/folder-setup');
const escape = require('escape-html');
const registryOpenApi = require('./openapi');
+const { FhirsmithReleases, isFhirsmith, describeAge } = require('./fhirsmith-releases');
// The query parameters of the public API. These are the contract published in openapi.yaml,
// and tests/registry/openapi.test.js checks that the two agree - so change both together.
@@ -58,6 +59,13 @@ class RegistryModule {
// Initialize API with crawler
this.api = new RegistryAPI(this.crawler);
+ // FHIRsmith release dates, for the software page. CHANGELOG.md until GitHub answers
+ this.releases = new FhirsmithReleases(crawlerConfig, this.logger);
+ this.releases.loadFromChangelog();
+ if (config.releasesUrl !== '') {
+ this.releases.refresh().catch(() => {});
+ }
+
// Load saved data if available
await this.loadSavedData();
@@ -153,6 +161,9 @@ class RegistryModule {
try {
// Perform the crawl
const newData = await this.crawler.crawl(this.config.masterUrl);
+ if (this.releases && this.config.releasesUrl !== '') {
+ await this.releases.refresh();
+ }
// Thread-safe update of current data
await this.updateData(() => {
@@ -241,6 +252,7 @@ class RegistryModule {
this.router.get('/', this.handleMainPage.bind(this));
this.router.get('/resolve', this.handleResolveEndpoint.bind(this));
this.router.get('/log', this.handleLogEndpoint.bind(this));
+ this.router.get('/software', this.handleSoftwarePage.bind(this));
// OpenAPI description of this API: /openapi.json, /openapi.yaml, and /openapi (an HTML
// reference for browsers, the JSON otherwise)
@@ -458,6 +470,114 @@ class RegistryModule {
}
}
+ /**
+ * The software page - what each registered server is running, and for FHIRsmith,
+ * how old that release is
+ */
+ async handleSoftwarePage(req, res) {
+ const start = Date.now();
+ try {
+ if (!htmlServer.hasTemplate('registry')) {
+ htmlServer.loadTemplate('registry', path.join(__dirname, 'registry-template.html'));
+ }
+ const startTime = Date.now();
+ const content = this.buildSoftwareContent();
+ const stats = this.api.getStatistics();
+ stats.processingTime = Date.now() - startTime;
+ const html = htmlServer.renderPage('registry', 'Terminology Server Software', content, stats);
+ res.setHeader('Content-Type', 'text/html');
+ res.send(html);
+ } catch (error) {
+ this.logger.error('Error rendering software page:', error);
+ res.status(500).send(`${escape(error.message)}
`); + } finally { + this.stats.countRequest('software', Date.now() - start); + } + } + + buildSoftwareContent(now = Date.now()) { + const data = this.api.getData(); + if (!data || !data.registries) { + return 'The initial crawl is in progress. Please refresh in a moment.
The current FHIRsmith release is v${escape(latest.version)}, released ` + + `${escape(latest.date.toISOString().substring(0, 10))}. ` + + 'Servers running older FHIRsmith releases are missing security fixes, both in FHIRsmith ' + + 'and in the libraries it depends on, and should be upgraded.
'; + } + + html += '| Server | URL | FHIR | Software | Version | ' + + 'Released | Age |
|---|---|---|---|---|---|---|
| ${escape(row.name)} | `; + html += `${escape(row.url)}`; + if (row.error) { + html += ` (unreachable)`; + } + html += ' | '; + html += `${escape(row.fhirVersion)} | `; + html += `${row.software ? escape(row.software.replace('Reference Server', 'HealthIntersections')) : 'unknown'} | `; + html += `${row.softwareVersion ? escape(row.softwareVersion) : 'unknown'} | `; + html += this._renderReleaseCells(row, now); + html += '
FHIRsmith release dates from ${escape(this.releases.source)}.
`; + } + return html; + } + + _renderReleaseCells(row, now) { + if (!isFhirsmith(row.software) || !this.releases) { + return 'Data: ${folders.dataDir()}
`; content = '