fhirsmith 0.3.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 +42 -0
- package/FHIRsmith.png +0 -0
- package/README.md +277 -0
- package/config-template.json +144 -0
- package/library/folder-setup.js +58 -0
- package/library/html-server.js +166 -0
- package/library/html.js +835 -0
- package/library/i18nsupport.js +259 -0
- package/library/languages.js +779 -0
- package/library/logger-telnet.js +205 -0
- package/library/logger.js +279 -0
- package/library/package-manager.js +876 -0
- package/library/utilities.js +196 -0
- package/library/version-utilities.js +1056 -0
- package/npmprojector/config-example.json +13 -0
- package/npmprojector/indexer.js +394 -0
- package/npmprojector/npmprojector.js +395 -0
- package/npmprojector/readme.md +174 -0
- package/npmprojector/watcher.js +335 -0
- package/package.json +119 -0
- package/packages/package-crawler.js +846 -0
- package/packages/packages-template.html +126 -0
- package/packages/packages.js +2838 -0
- package/passwords.ini +2 -0
- package/publisher/publisher-template.html +208 -0
- package/publisher/publisher.js +2167 -0
- package/publisher/task-draft.js +458 -0
- package/registry/api.js +735 -0
- package/registry/crawler.js +637 -0
- package/registry/model.js +513 -0
- package/registry/readme.md +243 -0
- package/registry/registry-data.json +121015 -0
- package/registry/registry-template.html +126 -0
- package/registry/registry.js +1395 -0
- package/registry/test-runner.js +237 -0
- package/root-template.html +124 -0
- package/server.js +524 -0
- package/shl/private-key.pem +5 -0
- package/shl/public-key.pem +18 -0
- package/shl/shl.js +1125 -0
- package/shl/vhl.js +69 -0
- package/static/FHIRsmith128.png +0 -0
- package/static/FHIRsmith16.png +0 -0
- package/static/FHIRsmith32.png +0 -0
- package/static/FHIRsmith64.png +0 -0
- package/static/assets/css/bootstrap-fhir.css +5302 -0
- package/static/assets/css/bootstrap-glyphicons.css +2 -0
- package/static/assets/css/bootstrap.css +4097 -0
- package/static/assets/css/jquery-ui.css +523 -0
- package/static/assets/css/jquery-ui.structure.css +863 -0
- package/static/assets/css/jquery-ui.structure.min.css +5 -0
- package/static/assets/css/jquery-ui.theme.css +439 -0
- package/static/assets/css/jquery-ui.theme.min.css +5 -0
- package/static/assets/css/jquery.ui.all.css +7 -0
- package/static/assets/css/modules.css +18 -0
- package/static/assets/css/project.css +367 -0
- package/static/assets/css/pygments-manni.css +66 -0
- package/static/assets/css/tags.css +74 -0
- package/static/assets/css/xml.css +2 -0
- package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
- package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
- package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
- package/static/assets/ico/favicon.ico +0 -0
- package/static/assets/ico/favicon.png +0 -0
- package/static/assets/images/fhir-logo-www.png +0 -0
- package/static/assets/images/fhir-logo.png +0 -0
- package/static/assets/images/hl7-logo.png +0 -0
- package/static/assets/images/logo_ansinew.jpg +0 -0
- package/static/assets/images/search.png +0 -0
- package/static/assets/images/stripe.png +0 -0
- package/static/assets/images/target.png +0 -0
- package/static/assets/images/tx-registry-root.gif +0 -0
- package/static/assets/images/tx-registry.png +0 -0
- package/static/assets/images/tx-server.png +0 -0
- package/static/assets/images/tx-version.png +0 -0
- package/static/assets/js/bootstrap.min.js +6 -0
- package/static/assets/js/fhir-gw.js +259 -0
- package/static/assets/js/fhir.js +2 -0
- package/static/assets/js/html5shiv.js +8 -0
- package/static/assets/js/jcookie.js +96 -0
- package/static/assets/js/jquery-ui.min.js +6 -0
- package/static/assets/js/jquery.js +10716 -0
- package/static/assets/js/jquery.min.js +2 -0
- package/static/assets/js/jquery.ui.core.js +314 -0
- package/static/assets/js/jquery.ui.draggable.js +825 -0
- package/static/assets/js/jquery.ui.mouse.js +162 -0
- package/static/assets/js/jquery.ui.resizable.js +842 -0
- package/static/assets/js/jquery.ui.widget.js +268 -0
- package/static/assets/js/json2.js +487 -0
- package/static/assets/js/jtip.js +97 -0
- package/static/assets/js/respond.min.js +6 -0
- package/static/assets/js/statuspage.js +70 -0
- package/static/assets/js/xml.js +2 -0
- package/static/dist/js/bootstrap.js +1964 -0
- package/static/favicon.png +0 -0
- package/static/fhir.css +626 -0
- package/static/icon-fhir-16.png +0 -0
- package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
- package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
- package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
- package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
- package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
- package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
- package/static/images/ui-icons_222222_256x240.png +0 -0
- package/static/images/ui-icons_228ef1_256x240.png +0 -0
- package/static/images/ui-icons_ef8c08_256x240.png +0 -0
- package/static/images/ui-icons_ffd27a_256x240.png +0 -0
- package/static/images/ui-icons_ffffff_256x240.png +0 -0
- package/static/js/jquery.effects.blind.js +49 -0
- package/static/js/jquery.effects.bounce.js +78 -0
- package/static/js/jquery.effects.clip.js +54 -0
- package/static/js/jquery.effects.core.js +763 -0
- package/static/js/jquery.effects.drop.js +50 -0
- package/static/js/jquery.effects.explode.js +79 -0
- package/static/js/jquery.effects.fade.js +32 -0
- package/static/js/jquery.effects.fold.js +56 -0
- package/static/js/jquery.effects.highlight.js +50 -0
- package/static/js/jquery.effects.pulsate.js +51 -0
- package/static/js/jquery.effects.scale.js +178 -0
- package/static/js/jquery.effects.shake.js +57 -0
- package/static/js/jquery.effects.slide.js +50 -0
- package/static/js/jquery.effects.transfer.js +45 -0
- package/static/js/jquery.ui.accordion.js +611 -0
- package/static/js/jquery.ui.autocomplete.js +612 -0
- package/static/js/jquery.ui.button.js +416 -0
- package/static/js/jquery.ui.datepicker.js +1823 -0
- package/static/js/jquery.ui.dialog.js +878 -0
- package/static/js/jquery.ui.droppable.js +296 -0
- package/static/js/jquery.ui.position.js +252 -0
- package/static/js/jquery.ui.progressbar.js +109 -0
- package/static/js/jquery.ui.selectable.js +266 -0
- package/static/js/jquery.ui.slider.js +666 -0
- package/static/js/jquery.ui.sortable.js +1077 -0
- package/static/js/jquery.ui.tabs.js +758 -0
- package/stats.js +80 -0
- package/test-cache/vsac/vsac-valuesets.db +0 -0
- package/token/nginx_passport_setup.md +383 -0
- package/token/security_guide.md +294 -0
- package/token/token-template.html +330 -0
- package/token/token.js +1300 -0
- package/translations/Messages.properties +1510 -0
- package/translations/Messages_ar.properties +1399 -0
- package/translations/Messages_de.properties +836 -0
- package/translations/Messages_es.properties +737 -0
- package/translations/Messages_fr.properties +1 -0
- package/translations/Messages_ja.properties +893 -0
- package/translations/Messages_nl.properties +1357 -0
- package/translations/Messages_pt.properties +1302 -0
- package/translations/Messages_ru.properties +1 -0
- package/translations/Messages_uz.properties +1 -0
- package/translations/Messages_zh.properties +1 -0
- package/translations/rendering-phrases.properties +1128 -0
- package/translations/rendering-phrases_ar.properties +1091 -0
- package/translations/rendering-phrases_de.properties +6 -0
- package/translations/rendering-phrases_es.properties +6 -0
- package/translations/rendering-phrases_fr.properties +624 -0
- package/translations/rendering-phrases_ja.properties +21 -0
- package/translations/rendering-phrases_nl.properties +970 -0
- package/translations/rendering-phrases_pt.properties +1020 -0
- package/translations/rendering-phrases_ru.properties +1094 -0
- package/translations/rendering-phrases_uz.properties +1 -0
- package/translations/rendering-phrases_zh.properties +1 -0
- package/tx/README.md +418 -0
- package/tx/cm/cm-api.js +110 -0
- package/tx/cm/cm-database.js +735 -0
- package/tx/cm/cm-package.js +325 -0
- package/tx/cs/cs-api.js +789 -0
- package/tx/cs/cs-areacode.js +615 -0
- package/tx/cs/cs-country.js +1110 -0
- package/tx/cs/cs-cpt.js +785 -0
- package/tx/cs/cs-cs.js +1579 -0
- package/tx/cs/cs-currency.js +539 -0
- package/tx/cs/cs-db.js +1321 -0
- package/tx/cs/cs-hgvs.js +329 -0
- package/tx/cs/cs-lang.js +465 -0
- package/tx/cs/cs-loinc.js +1485 -0
- package/tx/cs/cs-mimetypes.js +238 -0
- package/tx/cs/cs-ndc.js +704 -0
- package/tx/cs/cs-omop.js +1025 -0
- package/tx/cs/cs-provider-api.js +43 -0
- package/tx/cs/cs-provider-list.js +37 -0
- package/tx/cs/cs-rxnorm.js +808 -0
- package/tx/cs/cs-snomed.js +1102 -0
- package/tx/cs/cs-ucum.js +514 -0
- package/tx/cs/cs-unii.js +271 -0
- package/tx/cs/cs-uri.js +218 -0
- package/tx/cs/cs-usstates.js +305 -0
- package/tx/dev.fhir.org.yml +14 -0
- package/tx/fixtures/test-cases-setup.json +18 -0
- package/tx/fixtures/test-cases.yml +16 -0
- package/tx/html/codesystem-operations.liquid +25 -0
- package/tx/html/home-metrics.liquid +247 -0
- package/tx/html/operations-form.liquid +148 -0
- package/tx/html/search-form.liquid +62 -0
- package/tx/html/tx-template.html +133 -0
- package/tx/html/valueset-operations.liquid +54 -0
- package/tx/importers/atc-to-fhir.js +316 -0
- package/tx/importers/import-loinc.module.js +1536 -0
- package/tx/importers/import-ndc.module.js +1088 -0
- package/tx/importers/import-rxnorm.module.js +898 -0
- package/tx/importers/import-sct.module.js +2457 -0
- package/tx/importers/import-unii.module.js +601 -0
- package/tx/importers/readme.md +453 -0
- package/tx/importers/subset-loinc.module.js +1081 -0
- package/tx/importers/subset-rxnorm.module.js +938 -0
- package/tx/importers/tx-import-base.js +351 -0
- package/tx/importers/tx-import-settings.js +310 -0
- package/tx/importers/tx-import.js +357 -0
- package/tx/library/canonical-resource.js +88 -0
- package/tx/library/capabilitystatement.js +292 -0
- package/tx/library/codesystem.js +774 -0
- package/tx/library/conceptmap.js +568 -0
- package/tx/library/designations.js +932 -0
- package/tx/library/errors.js +77 -0
- package/tx/library/extensions.js +117 -0
- package/tx/library/namingsystem.js +322 -0
- package/tx/library/operation-outcome.js +127 -0
- package/tx/library/parameters.js +105 -0
- package/tx/library/renderer.js +1559 -0
- package/tx/library/terminologycapabilities.js +418 -0
- package/tx/library/ucum-parsers.js +1029 -0
- package/tx/library/ucum-service.js +370 -0
- package/tx/library/ucum-types.js +1099 -0
- package/tx/library/valueset.js +543 -0
- package/tx/library.js +676 -0
- package/tx/ocl/cm-ocl.js +106 -0
- package/tx/ocl/cs-ocl.js +39 -0
- package/tx/ocl/vs-ocl.js +105 -0
- package/tx/operation-context.js +568 -0
- package/tx/params.js +613 -0
- package/tx/provider.js +403 -0
- package/tx/sct/ecl.js +1560 -0
- package/tx/sct/expressions.js +2077 -0
- package/tx/sct/structures.js +1396 -0
- package/tx/tx-html.js +1063 -0
- package/tx/tx.fhir.org.yml +39 -0
- package/tx/tx.js +927 -0
- package/tx/vs/vs-api.js +112 -0
- package/tx/vs/vs-database.js +786 -0
- package/tx/vs/vs-package.js +358 -0
- package/tx/vs/vs-vsac.js +366 -0
- package/tx/workers/batch-validate.js +129 -0
- package/tx/workers/batch.js +361 -0
- package/tx/workers/closure.js +32 -0
- package/tx/workers/expand.js +1845 -0
- package/tx/workers/lookup.js +407 -0
- package/tx/workers/metadata.js +467 -0
- package/tx/workers/operations.js +34 -0
- package/tx/workers/read.js +164 -0
- package/tx/workers/search.js +384 -0
- package/tx/workers/subsumes.js +334 -0
- package/tx/workers/translate.js +492 -0
- package/tx/workers/validate.js +2504 -0
- package/tx/workers/worker.js +904 -0
- package/tx/xml/capabilitystatement-xml.js +63 -0
- package/tx/xml/codesystem-xml.js +62 -0
- package/tx/xml/conceptmap-xml.js +65 -0
- package/tx/xml/namingsystem-xml.js +65 -0
- package/tx/xml/operationoutcome-xml.js +127 -0
- package/tx/xml/parameters-xml.js +312 -0
- package/tx/xml/terminologycapabilities-xml.js +64 -0
- package/tx/xml/valueset-xml.js +64 -0
- package/tx/xml/xml-base.js +603 -0
- package/vcl/vcl-parser.js +1098 -0
- package/vcl/vcl.js +253 -0
- package/windows-install.js +19 -0
- package/xig/xig-template.html +124 -0
- package/xig/xig.js +3049 -0
|
@@ -0,0 +1,2838 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
const express = require('express');
|
|
8
|
+
const sqlite3 = require('sqlite3').verbose();
|
|
9
|
+
const cron = require('node-cron');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const PackageCrawler = require('./package-crawler.js');
|
|
13
|
+
const htmlServer = require('../library/html-server');
|
|
14
|
+
const folders = require('../library/folder-setup');
|
|
15
|
+
|
|
16
|
+
const Logger = require('../library/logger');
|
|
17
|
+
const {validateParameter} = require("../library/utilities");
|
|
18
|
+
const pckLog = Logger.getInstance().child({ module: 'packages' });
|
|
19
|
+
|
|
20
|
+
class PackagesModule {
|
|
21
|
+
constructor(stats) {
|
|
22
|
+
this.router = express.Router();
|
|
23
|
+
this.config = null;
|
|
24
|
+
this.db = null;
|
|
25
|
+
this.crawlerJob = null;
|
|
26
|
+
this.crawler = null;
|
|
27
|
+
this.lastRunTime = null;
|
|
28
|
+
this.totalRuns = 0;
|
|
29
|
+
this.lastCrawlerLog = {};
|
|
30
|
+
this.crawlerRunning = false;
|
|
31
|
+
this.setupSecurityMiddleware();
|
|
32
|
+
this.setupRoutes();
|
|
33
|
+
this.stats = stats;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setupSecurityMiddleware() {
|
|
37
|
+
// Security headers middleware
|
|
38
|
+
this.router.use((req, res, next) => {
|
|
39
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
40
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
41
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
42
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
43
|
+
res.setHeader('Content-Security-Policy', [
|
|
44
|
+
"default-src 'self'",
|
|
45
|
+
"script-src 'self' 'unsafe-inline'",
|
|
46
|
+
"style-src 'self' 'unsafe-inline'",
|
|
47
|
+
"img-src 'self' data: https:",
|
|
48
|
+
"font-src 'self'",
|
|
49
|
+
"connect-src 'self'",
|
|
50
|
+
"frame-ancestors 'none'"
|
|
51
|
+
].join('; '));
|
|
52
|
+
res.removeHeader('X-Powered-By');
|
|
53
|
+
next();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parameter validation middleware
|
|
58
|
+
validateQueryParams(allowedParams = {}) {
|
|
59
|
+
return (req, res, next) => {
|
|
60
|
+
try {
|
|
61
|
+
// Check for parameter pollution (arrays) and validate
|
|
62
|
+
const normalized = {};
|
|
63
|
+
|
|
64
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
return res.status(400).json({
|
|
67
|
+
error: 'Parameter pollution detected',
|
|
68
|
+
parameter: key
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (allowedParams[key]) {
|
|
73
|
+
const config = allowedParams[key];
|
|
74
|
+
|
|
75
|
+
if (value !== undefined) {
|
|
76
|
+
if (typeof value !== 'string') {
|
|
77
|
+
return res.status(400).json({
|
|
78
|
+
error: `Parameter ${key} must be a string`
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (value.length > (config.maxLength || 255)) {
|
|
83
|
+
return res.status(400).json({
|
|
84
|
+
error: `Parameter ${key} too long (max ${config.maxLength || 255})`
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (config.pattern && !config.pattern.test(value)) {
|
|
89
|
+
return res.status(400).json({
|
|
90
|
+
error: `Parameter ${key} has invalid format`
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
normalized[key] = value;
|
|
95
|
+
} else if (config.required) {
|
|
96
|
+
return res.status(400).json({
|
|
97
|
+
error: `Parameter ${key} is required`
|
|
98
|
+
});
|
|
99
|
+
} else {
|
|
100
|
+
normalized[key] = config.default || '';
|
|
101
|
+
}
|
|
102
|
+
} else if (value !== undefined) {
|
|
103
|
+
// Unknown parameter
|
|
104
|
+
return res.status(400).json({
|
|
105
|
+
error: `Unknown parameter: ${key}`
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Set default values for missing optional parameters
|
|
111
|
+
for (const [key, config] of Object.entries(allowedParams)) {
|
|
112
|
+
if (normalized[key] === undefined && !config.required) {
|
|
113
|
+
normalized[key] = config.default || '';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Clear and repopulate in-place (Express 5 makes req.query a read-only getter)
|
|
118
|
+
for (const key of Object.keys(req.query)) delete req.query[key];
|
|
119
|
+
Object.assign(req.query, normalized);
|
|
120
|
+
next();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
pckLog.error('Parameter validation error:', error);
|
|
123
|
+
res.status(500).json({ error: 'Parameter validation failed' });
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Enhanced HTML escaping
|
|
129
|
+
escapeHtml(str) {
|
|
130
|
+
if (!str || typeof str !== 'string') return '';
|
|
131
|
+
|
|
132
|
+
const escapeMap = {
|
|
133
|
+
'&': '&',
|
|
134
|
+
'<': '<',
|
|
135
|
+
'>': '>',
|
|
136
|
+
'"': '"',
|
|
137
|
+
"'": ''',
|
|
138
|
+
'/': '/',
|
|
139
|
+
'`': '`',
|
|
140
|
+
'=': '='
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return str.replace(/[&<>"'`=/]/g, (match) => escapeMap[match]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
buildSecureQuery(baseQuery, conditions = []) {
|
|
147
|
+
let query = baseQuery;
|
|
148
|
+
const params = [];
|
|
149
|
+
|
|
150
|
+
conditions.forEach(condition => {
|
|
151
|
+
if (condition.operator === 'LIKE') {
|
|
152
|
+
query += ` AND ${condition.column} LIKE ?`;
|
|
153
|
+
params.push(`%${condition.value}%`);
|
|
154
|
+
} else if (condition.operator === '=') {
|
|
155
|
+
query += ` AND ${condition.column} = ?`;
|
|
156
|
+
params.push(condition.value);
|
|
157
|
+
} else if (condition.operator === 'IN') {
|
|
158
|
+
const placeholders = condition.values.map(() => '?').join(',');
|
|
159
|
+
query += ` AND ${condition.column} IN (${placeholders})`;
|
|
160
|
+
params.push(...condition.values);
|
|
161
|
+
} else if (condition.operator === 'IN_SUBQUERY') {
|
|
162
|
+
query += ` AND ${condition.column} IN (${condition.subquery})`;
|
|
163
|
+
params.push(condition.value);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return { query, params };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async searchPackages(params, req = null, secure = false) {
|
|
170
|
+
const {
|
|
171
|
+
name = '',
|
|
172
|
+
dependson = '',
|
|
173
|
+
canonicalPkg = '',
|
|
174
|
+
canonicalUrl = '',
|
|
175
|
+
fhirVersion = '',
|
|
176
|
+
dependency = '',
|
|
177
|
+
sort = ''
|
|
178
|
+
} = params;
|
|
179
|
+
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
try {
|
|
182
|
+
let baseQuery;
|
|
183
|
+
const conditions = [];
|
|
184
|
+
let versioned = false;
|
|
185
|
+
|
|
186
|
+
// Build base query and conditions
|
|
187
|
+
if (name) {
|
|
188
|
+
versioned = name.includes('#');
|
|
189
|
+
if (name.includes('#')) {
|
|
190
|
+
const [packageId, version] = name.split('#');
|
|
191
|
+
conditions.push({ column: 'PackageVersions.Id', operator: 'LIKE', value: packageId });
|
|
192
|
+
conditions.push({ column: 'PackageVersions.Version', operator: 'LIKE', value: version });
|
|
193
|
+
} else {
|
|
194
|
+
conditions.push({ column: 'PackageVersions.Id', operator: 'LIKE', value: name });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Add the missing dependency search logic
|
|
199
|
+
if (dependson) {
|
|
200
|
+
validateParameter(dependson, "dependson", String);
|
|
201
|
+
versioned = dependson.includes('#');
|
|
202
|
+
// This requires a subquery to PackageDependencies table
|
|
203
|
+
conditions.push({
|
|
204
|
+
column: 'PackageVersions.PackageVersionKey',
|
|
205
|
+
operator: 'IN_SUBQUERY',
|
|
206
|
+
subquery: 'SELECT PackageVersionKey FROM PackageDependencies WHERE Dependency LIKE ?',
|
|
207
|
+
value: `%${dependson}%`
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (canonicalPkg) {
|
|
212
|
+
if (canonicalPkg.endsWith('%')) {
|
|
213
|
+
conditions.push({ column: 'PackageVersions.Canonical', operator: 'LIKE', value: canonicalPkg.slice(0, -1) });
|
|
214
|
+
} else {
|
|
215
|
+
conditions.push({ column: 'PackageVersions.Canonical', operator: '=', value: canonicalPkg });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Add canonical URL search (requires PackageURLs table)
|
|
220
|
+
if (canonicalUrl) {
|
|
221
|
+
conditions.push({
|
|
222
|
+
column: 'PackageVersions.PackageVersionKey',
|
|
223
|
+
operator: 'IN_SUBQUERY',
|
|
224
|
+
subquery: 'SELECT PackageVersionKey FROM PackageURLs WHERE URL LIKE ?',
|
|
225
|
+
value: `${canonicalUrl}%`
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Add FHIR version search (requires PackageFHIRVersions table)
|
|
230
|
+
if (fhirVersion) {
|
|
231
|
+
const mappedVersion = this.getVersion(fhirVersion);
|
|
232
|
+
conditions.push({
|
|
233
|
+
column: 'PackageVersions.PackageVersionKey',
|
|
234
|
+
operator: 'IN_SUBQUERY',
|
|
235
|
+
subquery: 'SELECT PackageVersionKey FROM PackageFHIRVersions WHERE Version LIKE ?',
|
|
236
|
+
value: `${mappedVersion}%`
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Add dependency search
|
|
241
|
+
if (dependency) {
|
|
242
|
+
validateParameter(dependency, "dependency", String);
|
|
243
|
+
let depQuery;
|
|
244
|
+
if (dependency.includes('#')) {
|
|
245
|
+
depQuery = `${dependency}%`;
|
|
246
|
+
} else if (dependency.includes('|')) {
|
|
247
|
+
depQuery = `${dependency.replace('|', '#')}%`;
|
|
248
|
+
} else {
|
|
249
|
+
depQuery = `${dependency}#%`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
conditions.push({
|
|
253
|
+
column: 'PackageVersions.PackageVersionKey',
|
|
254
|
+
operator: 'IN_SUBQUERY',
|
|
255
|
+
subquery: 'SELECT PackageVersionKey FROM PackageDependencies WHERE Dependency LIKE ?',
|
|
256
|
+
value: depQuery
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Build appropriate base query
|
|
261
|
+
if (versioned) {
|
|
262
|
+
baseQuery = `SELECT Id, Version, PubDate, FhirVersions, Kind, Canonical, Description
|
|
263
|
+
FROM PackageVersions
|
|
264
|
+
WHERE PackageVersions.PackageVersionKey > 0`;
|
|
265
|
+
} else {
|
|
266
|
+
baseQuery = `SELECT Packages.Id, Version, PubDate, FhirVersions, Kind,
|
|
267
|
+
PackageVersions.Canonical, Packages.DownloadCount, Description
|
|
268
|
+
FROM Packages, PackageVersions
|
|
269
|
+
WHERE Packages.CurrentVersion = PackageVersions.PackageVersionKey`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const { query, params: queryParams } = this.buildSecureQuery(baseQuery, conditions);
|
|
273
|
+
|
|
274
|
+
this.db.all(query + ' ORDER BY PubDate', queryParams, (err, rows) => {
|
|
275
|
+
if (err) {
|
|
276
|
+
reject(err);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const results = rows.map(row => {
|
|
281
|
+
const packageInfo = {
|
|
282
|
+
name: row.Id,
|
|
283
|
+
version: row.Version,
|
|
284
|
+
fhirVersion: this.interpretVersion(row.FhirVersions),
|
|
285
|
+
canonical: row.Canonical,
|
|
286
|
+
kind: this.codeForKind(row.Kind),
|
|
287
|
+
url: this.buildPackageUrl(row.Id, row.Version, secure, req)
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
if (row.PubDate) {
|
|
291
|
+
packageInfo.date = new Date(row.PubDate).toISOString();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!versioned && row.DownloadCount) {
|
|
295
|
+
packageInfo.count = row.DownloadCount;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (row.Description) {
|
|
299
|
+
packageInfo.description = Buffer.isBuffer(row.Description)
|
|
300
|
+
? row.Description.toString('utf8')
|
|
301
|
+
: row.Description;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return packageInfo;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
resolve(this.applySorting(results, sort));
|
|
308
|
+
});
|
|
309
|
+
} catch (error) {
|
|
310
|
+
reject(error);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// URL validation for external requests
|
|
316
|
+
validateExternalUrl(url) {
|
|
317
|
+
try {
|
|
318
|
+
const parsed = new URL(url);
|
|
319
|
+
|
|
320
|
+
// Only allow http and https
|
|
321
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
322
|
+
throw new Error(`Protocol ${parsed.protocol} not allowed`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Block private IP ranges
|
|
326
|
+
const hostname = parsed.hostname;
|
|
327
|
+
if (hostname === 'localhost' ||
|
|
328
|
+
hostname === '127.0.0.1' ||
|
|
329
|
+
hostname.startsWith('10.') ||
|
|
330
|
+
hostname.startsWith('192.168.') ||
|
|
331
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname)) {
|
|
332
|
+
throw new Error('Private IP addresses not allowed');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return parsed;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
throw new Error(`Invalid URL: ${error.message}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Safe HTTP request function
|
|
342
|
+
async safeHttpRequest(url, options = {}) {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
try {
|
|
345
|
+
const validatedUrl = this.validateExternalUrl(url);
|
|
346
|
+
const { maxSize = 50 * 1024 * 1024, timeout = 30000 } = options;
|
|
347
|
+
|
|
348
|
+
const protocol = validatedUrl.protocol === 'https:' ? require('https') : require('http');
|
|
349
|
+
|
|
350
|
+
const request = protocol.get(validatedUrl, (response) => {
|
|
351
|
+
// Check content length
|
|
352
|
+
const contentLength = parseInt(response.headers['content-length'] || '0');
|
|
353
|
+
if (contentLength > maxSize) {
|
|
354
|
+
request.destroy();
|
|
355
|
+
reject(new Error('Response too large'));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Handle redirects safely
|
|
360
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
361
|
+
const location = response.headers.location;
|
|
362
|
+
if (!location) {
|
|
363
|
+
reject(new Error('Redirect without location'));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const redirectCount = options.redirectCount || 0;
|
|
368
|
+
if (redirectCount >= 5) {
|
|
369
|
+
reject(new Error('Too many redirects'));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this.safeHttpRequest(location, { ...options, redirectCount: redirectCount + 1 })
|
|
374
|
+
.then(resolve)
|
|
375
|
+
.catch(reject);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (response.statusCode !== 200) {
|
|
380
|
+
reject(new Error(`HTTP ${response.statusCode}`));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let data = Buffer.alloc(0);
|
|
385
|
+
response.on('data', (chunk) => {
|
|
386
|
+
data = Buffer.concat([data, chunk]);
|
|
387
|
+
if (data.length > maxSize) {
|
|
388
|
+
request.destroy();
|
|
389
|
+
reject(new Error('Response too large'));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
response.on('end', () => {
|
|
395
|
+
resolve(data);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
request.on('error', reject);
|
|
400
|
+
request.setTimeout(timeout, () => {
|
|
401
|
+
request.destroy();
|
|
402
|
+
reject(new Error('Request timeout'));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
} catch (error) {
|
|
406
|
+
reject(error);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async gatherPackageStatistics() {
|
|
412
|
+
try {
|
|
413
|
+
// Get database age info
|
|
414
|
+
const dbAge = this.getDatabaseAgeInfo();
|
|
415
|
+
let downloadDate = 'Unknown';
|
|
416
|
+
|
|
417
|
+
if (dbAge.lastModified) {
|
|
418
|
+
downloadDate = dbAge.lastModified.toISOString().split('T')[0];
|
|
419
|
+
} else {
|
|
420
|
+
downloadDate = 'Never';
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Get counts from database
|
|
424
|
+
const tableCounts = await this.getDatabaseTableCounts();
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
downloadDate: downloadDate,
|
|
428
|
+
totalResources: 0, // Packages don't track individual resources
|
|
429
|
+
totalPackages: tableCounts.packages || 0,
|
|
430
|
+
totalVersions: tableCounts.packageVersions || 0,
|
|
431
|
+
version: '4.0.1',
|
|
432
|
+
crawlerEnabled: this.config.crawler.enabled,
|
|
433
|
+
lastCrawlerRun: this.lastRunTime,
|
|
434
|
+
totalCrawlerRuns: this.totalRuns
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
} catch (error) {
|
|
438
|
+
pckLog.error(`Error gathering package statistics: ${error.message}`);
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
downloadDate: 'Error',
|
|
442
|
+
totalResources: 0,
|
|
443
|
+
totalPackages: 0,
|
|
444
|
+
totalVersions: 0,
|
|
445
|
+
version: '4.0.1',
|
|
446
|
+
crawlerEnabled: false,
|
|
447
|
+
lastCrawlerRun: null,
|
|
448
|
+
totalCrawlerRuns: 0
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
getDatabaseAgeInfo() {
|
|
454
|
+
if (!fs.existsSync(this.config.database)) {
|
|
455
|
+
return {
|
|
456
|
+
lastModified: null,
|
|
457
|
+
daysOld: null,
|
|
458
|
+
status: 'No database file'
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const stats = fs.statSync(this.config.database);
|
|
463
|
+
const lastModified = stats.mtime;
|
|
464
|
+
const now = new Date();
|
|
465
|
+
const ageInDays = Math.floor((now - lastModified) / (1000 * 60 * 60 * 24));
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
lastModified: lastModified,
|
|
469
|
+
daysOld: ageInDays,
|
|
470
|
+
status: ageInDays === 0 ? 'Today' :
|
|
471
|
+
ageInDays === 1 ? '1 day ago' :
|
|
472
|
+
`${ageInDays} days ago`
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async getDatabaseTableCounts() {
|
|
477
|
+
return new Promise((resolve) => {
|
|
478
|
+
if (!this.db) {
|
|
479
|
+
resolve({packages: 0, packageVersions: 0});
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const counts = {};
|
|
484
|
+
let completedQueries = 0;
|
|
485
|
+
const totalQueries = 2;
|
|
486
|
+
|
|
487
|
+
this.db.get('SELECT COUNT(*) as count FROM Packages', [], (err, row) => {
|
|
488
|
+
if (err) {
|
|
489
|
+
counts.packages = 0;
|
|
490
|
+
} else {
|
|
491
|
+
counts.packages = row ? row.count : 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
completedQueries++;
|
|
495
|
+
if (completedQueries === totalQueries) {
|
|
496
|
+
resolve(counts);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
this.db.get('SELECT COUNT(*) as count FROM PackageVersions', [], (err, row) => {
|
|
501
|
+
if (err) {
|
|
502
|
+
counts.packageVersions = 0;
|
|
503
|
+
} else {
|
|
504
|
+
counts.packageVersions = row ? row.count : 0;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
completedQueries++;
|
|
508
|
+
if (completedQueries === totalQueries) {
|
|
509
|
+
resolve(counts);
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
buildPackagesMainPageContent() {
|
|
516
|
+
let content = '<div class="row mb-4">';
|
|
517
|
+
content += '<div class="col-12">';
|
|
518
|
+
content += '<h1>FHIR Package Server</h1>';
|
|
519
|
+
content += '<p class="lead">Browse and search FHIR Implementation Guide packages</p>';
|
|
520
|
+
content += '</div>';
|
|
521
|
+
content += '</div>';
|
|
522
|
+
|
|
523
|
+
// Status overview
|
|
524
|
+
content += '<div class="row mb-4">';
|
|
525
|
+
content += '<div class="col-md-6">';
|
|
526
|
+
content += '<div class="card">';
|
|
527
|
+
content += '<div class="card-header"><h5>Server Status</h5></div>';
|
|
528
|
+
content += '<div class="card-body">';
|
|
529
|
+
content += `<p><strong>Crawler:</strong> ${this.config.crawler.enabled ? 'Enabled' : 'Disabled'}</p>`;
|
|
530
|
+
if (this.lastRunTime) {
|
|
531
|
+
content += `<p><strong>Last Crawl:</strong> ${new Date(this.lastRunTime).toLocaleString()}</p>`;
|
|
532
|
+
}
|
|
533
|
+
content += `<p><strong>Total Runs:</strong> ${this.totalRuns}</p>`;
|
|
534
|
+
content += `<p><a href="/packages/stats" class="btn btn-info">View Statistics</a></p>`;
|
|
535
|
+
content += '</div>';
|
|
536
|
+
content += '</div>';
|
|
537
|
+
content += '</div>';
|
|
538
|
+
|
|
539
|
+
// Quick actions
|
|
540
|
+
content += '<div class="col-md-6">';
|
|
541
|
+
content += '<div class="card">';
|
|
542
|
+
content += '<div class="card-header"><h5>Quick Actions</h5></div>';
|
|
543
|
+
content += '<div class="card-body">';
|
|
544
|
+
content += '<p><a href="/packages/search" class="btn btn-primary mb-2">Search Packages</a></p>';
|
|
545
|
+
content += '<p><a href="/packages/log" class="btn btn-secondary mb-2">View Crawler Log</a></p>';
|
|
546
|
+
if (this.config.crawler.enabled) {
|
|
547
|
+
content += '<p><button onclick="triggerCrawl()" class="btn btn-warning mb-2">Manual Crawl</button></p>';
|
|
548
|
+
}
|
|
549
|
+
content += '</div>';
|
|
550
|
+
content += '</div>';
|
|
551
|
+
content += '</div>';
|
|
552
|
+
content += '</div>';
|
|
553
|
+
|
|
554
|
+
return content;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async initialize(config) {
|
|
558
|
+
this.config = config;
|
|
559
|
+
|
|
560
|
+
// Set default masterUrl if not configured
|
|
561
|
+
if (!this.config.masterUrl) {
|
|
562
|
+
this.config.masterUrl = 'https://fhir.github.io/ig-registry/package-feeds.json';
|
|
563
|
+
pckLog.info('No masterUrl configured, using default:', this.config.masterUrl);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
pckLog.info('Initializing Packages module...');
|
|
567
|
+
|
|
568
|
+
// Initialize database
|
|
569
|
+
await this.initializeDatabase();
|
|
570
|
+
|
|
571
|
+
// Ensure mirror directory exists
|
|
572
|
+
await this.ensureMirrorDirectory();
|
|
573
|
+
|
|
574
|
+
// Initialize the crawler
|
|
575
|
+
this.crawler = new PackageCrawler(this.config, this.db);
|
|
576
|
+
|
|
577
|
+
// Start the hourly web crawler if enabled
|
|
578
|
+
if (config.crawler.enabled) {
|
|
579
|
+
// Start the scheduled job
|
|
580
|
+
this.startCrawlerJob();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
pckLog.info('Packages module initialized successfully');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async runCrawler() {
|
|
587
|
+
this.totalRuns++;
|
|
588
|
+
pckLog.info(`Running package crawler (run #${this.totalRuns})...`);
|
|
589
|
+
this.crawlerRunning = true;
|
|
590
|
+
try {
|
|
591
|
+
try {
|
|
592
|
+
this.lastCrawlerLog = await this.crawler.crawl(pckLog);
|
|
593
|
+
this.lastCrawlerLog.runNumber = this.totalRuns;
|
|
594
|
+
this.lastRunTime = new Date().toISOString();
|
|
595
|
+
|
|
596
|
+
pckLog.info(`Package crawler completed successfully`);
|
|
597
|
+
return this.lastCrawlerLog;
|
|
598
|
+
} catch (error) {
|
|
599
|
+
this.lastRunTime = new Date().toISOString();
|
|
600
|
+
if (this.crawler.crawlerLog) {
|
|
601
|
+
this.lastCrawlerLog = this.crawler.crawlerLog;
|
|
602
|
+
this.lastCrawlerLog.runNumber = this.totalRuns;
|
|
603
|
+
}
|
|
604
|
+
pckLog.error('Package crawler failed:', error.message);
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
} finally {
|
|
608
|
+
this.crawlerRunning = false;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async initializeDatabase() {
|
|
613
|
+
return new Promise((resolve, reject) => {
|
|
614
|
+
// Use config path if absolute, otherwise resolve relative to data dir
|
|
615
|
+
const dbPath = path.isAbsolute(this.config.database) ? this.config.database : folders.filePath('packages', this.config.database);
|
|
616
|
+
|
|
617
|
+
// Ensure directory exists
|
|
618
|
+
const dbDir = path.dirname(dbPath);
|
|
619
|
+
if (!fs.existsSync(dbDir)) {
|
|
620
|
+
fs.mkdirSync(dbDir, {recursive: true});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const dbExists = fs.existsSync(dbPath);
|
|
624
|
+
|
|
625
|
+
this.db = new sqlite3.Database(dbPath, (err) => {
|
|
626
|
+
if (err) {
|
|
627
|
+
pckLog.error('Error opening packages database:', err.message);
|
|
628
|
+
reject(err);
|
|
629
|
+
} else {
|
|
630
|
+
pckLog.info('Connected to packages SQLite database:', dbPath);
|
|
631
|
+
|
|
632
|
+
if (!dbExists) {
|
|
633
|
+
pckLog.info('Database does not exist, creating tables...');
|
|
634
|
+
this.createTables().then(resolve).catch(reject);
|
|
635
|
+
} else {
|
|
636
|
+
pckLog.info('Packages database already exists');
|
|
637
|
+
resolve();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
this.db.run('PRAGMA journal_mode = WAL');
|
|
642
|
+
this.db.run('PRAGMA busy_timeout = 5000');
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async createTables() {
|
|
647
|
+
return new Promise((resolve, reject) => {
|
|
648
|
+
const tables = [
|
|
649
|
+
// Packages table
|
|
650
|
+
`CREATE TABLE Packages
|
|
651
|
+
(
|
|
652
|
+
PackageKey INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
653
|
+
Id TEXT(64) NOT NULL,
|
|
654
|
+
Canonical TEXT(128) NOT NULL,
|
|
655
|
+
DownloadCount INTEGER NOT NULL,
|
|
656
|
+
Security INTEGER,
|
|
657
|
+
ManualToken TEXT(64),
|
|
658
|
+
CurrentVersion INTEGER NOT NULL
|
|
659
|
+
)`,
|
|
660
|
+
|
|
661
|
+
// PackageVersions table
|
|
662
|
+
`CREATE TABLE PackageVersions
|
|
663
|
+
(
|
|
664
|
+
PackageVersionKey INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
665
|
+
GUID TEXT(128) NOT NULL,
|
|
666
|
+
PubDate DATETIME NOT NULL,
|
|
667
|
+
Indexed DATETIME NOT NULL,
|
|
668
|
+
Id TEXT(64) NOT NULL,
|
|
669
|
+
Version TEXT(64) NOT NULL,
|
|
670
|
+
Kind INTEGER NOT NULL,
|
|
671
|
+
UploadCount INTEGER,
|
|
672
|
+
DownloadCount INTEGER NOT NULL,
|
|
673
|
+
ManualToken TEXT(64),
|
|
674
|
+
Canonical TEXT(255) NOT NULL,
|
|
675
|
+
FhirVersions TEXT(255) NOT NULL,
|
|
676
|
+
Hash TEXT(128) NOT NULL,
|
|
677
|
+
Author TEXT(128) NOT NULL,
|
|
678
|
+
License TEXT(128) NOT NULL,
|
|
679
|
+
HomePage TEXT(128) NOT NULL,
|
|
680
|
+
Description BLOB,
|
|
681
|
+
Content BLOB NOT NULL
|
|
682
|
+
)`,
|
|
683
|
+
|
|
684
|
+
// PackageFHIRVersions table
|
|
685
|
+
`CREATE TABLE PackageFHIRVersions
|
|
686
|
+
(
|
|
687
|
+
PackageVersionKey INTEGER NOT NULL,
|
|
688
|
+
Version TEXT(128) NOT NULL
|
|
689
|
+
)`,
|
|
690
|
+
|
|
691
|
+
// PackageDependencies table
|
|
692
|
+
`CREATE TABLE PackageDependencies
|
|
693
|
+
(
|
|
694
|
+
PackageVersionKey INTEGER NOT NULL,
|
|
695
|
+
Dependency TEXT(128) NOT NULL
|
|
696
|
+
)`,
|
|
697
|
+
|
|
698
|
+
// PackageURLs table
|
|
699
|
+
`CREATE TABLE PackageURLs
|
|
700
|
+
(
|
|
701
|
+
PackageVersionKey INTEGER NOT NULL,
|
|
702
|
+
URL TEXT(128) NOT NULL
|
|
703
|
+
)`,
|
|
704
|
+
|
|
705
|
+
// PackagePermissions table
|
|
706
|
+
`CREATE TABLE PackagePermissions
|
|
707
|
+
(
|
|
708
|
+
PackagePermissionKey INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
709
|
+
ManualToken TEXT(64) NOT NULL,
|
|
710
|
+
Email TEXT(128) NOT NULL,
|
|
711
|
+
Mask TEXT(64)
|
|
712
|
+
)`
|
|
713
|
+
];
|
|
714
|
+
|
|
715
|
+
const indexes = [
|
|
716
|
+
'CREATE INDEX SK_Packages_Id ON Packages (Id, PackageKey)',
|
|
717
|
+
'CREATE INDEX SK_Packages_Canonical ON Packages (Canonical, PackageKey)',
|
|
718
|
+
'CREATE INDEX SK_PackageVersions_Id ON PackageVersions (Id, Version, PackageVersionKey)',
|
|
719
|
+
'CREATE INDEX SK_PackageVersions_Canonical ON PackageVersions (Canonical, PackageVersionKey)',
|
|
720
|
+
'CREATE INDEX SK_PackageVersions_PubDate ON PackageVersions (Id, PubDate, PackageVersionKey)',
|
|
721
|
+
'CREATE INDEX SK_PackageVersions_Indexed ON PackageVersions (Indexed, PackageVersionKey)',
|
|
722
|
+
'CREATE INDEX SK_PackageVersions_GUID ON PackageVersions (GUID)',
|
|
723
|
+
'CREATE INDEX SK_PackageFHIRVersions ON PackageFHIRVersions (PackageVersionKey)',
|
|
724
|
+
'CREATE INDEX SK_PackageDependencies ON PackageDependencies (PackageVersionKey)',
|
|
725
|
+
'CREATE INDEX SK_PackageURLs ON PackageURLs (PackageVersionKey)',
|
|
726
|
+
'CREATE INDEX SK_PackagePermissions_Token ON PackagePermissions (ManualToken)'
|
|
727
|
+
];
|
|
728
|
+
|
|
729
|
+
// First create all tables
|
|
730
|
+
let tablesCompleted = 0;
|
|
731
|
+
const totalTables = tables.length;
|
|
732
|
+
|
|
733
|
+
const checkTablesComplete = () => {
|
|
734
|
+
tablesCompleted++;
|
|
735
|
+
if (tablesCompleted === totalTables) {
|
|
736
|
+
pckLog.info('All packages database tables created successfully');
|
|
737
|
+
// Now create indexes
|
|
738
|
+
createIndexes();
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const createIndexes = () => {
|
|
743
|
+
let indexesCompleted = 0;
|
|
744
|
+
const totalIndexes = indexes.length;
|
|
745
|
+
|
|
746
|
+
const checkIndexesComplete = () => {
|
|
747
|
+
indexesCompleted++;
|
|
748
|
+
if (indexesCompleted === totalIndexes) {
|
|
749
|
+
pckLog.info('All packages database indexes created successfully');
|
|
750
|
+
resolve();
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const handleIndexError = (err) => {
|
|
755
|
+
pckLog.error('Error creating packages database index:', err);
|
|
756
|
+
reject(err);
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
// Create indexes
|
|
760
|
+
indexes.forEach(sql => {
|
|
761
|
+
this.db.run(sql, (err) => {
|
|
762
|
+
if (err) {
|
|
763
|
+
handleIndexError(err);
|
|
764
|
+
} else {
|
|
765
|
+
checkIndexesComplete();
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const handleTableError = (err) => {
|
|
772
|
+
pckLog.error('Error creating packages database table:', err);
|
|
773
|
+
reject(err);
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// Create tables first
|
|
777
|
+
tables.forEach(sql => {
|
|
778
|
+
this.db.run(sql, (err) => {
|
|
779
|
+
if (err) {
|
|
780
|
+
handleTableError(err);
|
|
781
|
+
} else {
|
|
782
|
+
checkTablesComplete();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async ensureMirrorDirectory() {
|
|
790
|
+
try {
|
|
791
|
+
const mirrorPath = this.config.mirrorPath;
|
|
792
|
+
|
|
793
|
+
if (!fs.existsSync(mirrorPath)) {
|
|
794
|
+
fs.mkdirSync(mirrorPath, {recursive: true});
|
|
795
|
+
pckLog.info('Created mirror directory:', mirrorPath);
|
|
796
|
+
} else {
|
|
797
|
+
pckLog.info('Mirror directory exists:', mirrorPath);
|
|
798
|
+
}
|
|
799
|
+
} catch (error) {
|
|
800
|
+
pckLog.error('Error creating mirror directory:', error);
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
startCrawlerJob() {
|
|
806
|
+
if (this.config.crawler && this.config.crawler.schedule) {
|
|
807
|
+
this.crawlerJob = cron.schedule(this.config.crawler.schedule, async () => {
|
|
808
|
+
pckLog.info('Starting scheduled package crawler...');
|
|
809
|
+
try {
|
|
810
|
+
await this.runCrawler();
|
|
811
|
+
pckLog.info('Scheduled package crawler completed successfully');
|
|
812
|
+
} catch (error) {
|
|
813
|
+
pckLog.error('Scheduled package crawler failed:', error.message);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
pckLog.info(`Package crawler scheduled job started: ${this.config.crawler.schedule}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
stopCrawlerJob() {
|
|
821
|
+
if (this.crawlerJob) {
|
|
822
|
+
this.crawlerJob.stop();
|
|
823
|
+
this.crawlerJob = null;
|
|
824
|
+
pckLog.info('Package crawler job stopped');
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async runWebCrawler() {
|
|
829
|
+
const startTime = Date.now();
|
|
830
|
+
this.totalRuns++;
|
|
831
|
+
this.crawlerLog = {
|
|
832
|
+
runNumber: this.totalRuns,
|
|
833
|
+
startTime: new Date().toISOString(),
|
|
834
|
+
master: this.config.masterUrl,
|
|
835
|
+
feeds: [],
|
|
836
|
+
totalBytes: 0,
|
|
837
|
+
errors: ''
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
pckLog.info(`Running web crawler for packages (run #${this.totalRuns})...`);
|
|
841
|
+
pckLog.info('Fetching master URL:', this.config.masterUrl);
|
|
842
|
+
|
|
843
|
+
try {
|
|
844
|
+
// Fetch the master JSON file
|
|
845
|
+
const masterResponse = await this.fetchJson(this.config.masterUrl);
|
|
846
|
+
|
|
847
|
+
if (!masterResponse.feeds || !Array.isArray(masterResponse.feeds)) {
|
|
848
|
+
throw new Error('Invalid master JSON: missing feeds array');
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Process package restrictions if available
|
|
852
|
+
const packageRestrictions = masterResponse['package-restrictions'] || [];
|
|
853
|
+
|
|
854
|
+
// Process each feed
|
|
855
|
+
for (const feedConfig of masterResponse.feeds) {
|
|
856
|
+
if (!feedConfig.url) {
|
|
857
|
+
pckLog.info('Skipping feed with no URL:', feedConfig);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
try {
|
|
862
|
+
await this.updateTheFeed(
|
|
863
|
+
this.fixUrl(feedConfig.url),
|
|
864
|
+
this.config.masterUrl,
|
|
865
|
+
feedConfig.errors ? feedConfig.errors.replace(/\|/g, '@').replace(/_/g, '.') : '',
|
|
866
|
+
packageRestrictions
|
|
867
|
+
);
|
|
868
|
+
} catch (feedError) {
|
|
869
|
+
pckLog.error(`Failed to process feed ${feedConfig.url}:`, feedError.message);
|
|
870
|
+
// Continue with next feed even if this one fails
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const runTime = Date.now() - startTime;
|
|
875
|
+
this.crawlerLog.runTime = `${runTime}ms`;
|
|
876
|
+
this.crawlerLog.endTime = new Date().toISOString();
|
|
877
|
+
this.crawlerLog.totalBytes = this.totalBytes;
|
|
878
|
+
this.lastRunTime = new Date().toISOString();
|
|
879
|
+
|
|
880
|
+
pckLog.info(`Web crawler completed successfully in ${runTime}ms`);
|
|
881
|
+
pckLog.info(`Total bytes processed: ${this.totalBytes}`);
|
|
882
|
+
|
|
883
|
+
} catch (error) {
|
|
884
|
+
const runTime = Date.now() - startTime;
|
|
885
|
+
this.crawlerLog.runTime = `${runTime}ms`;
|
|
886
|
+
this.crawlerLog.fatalException = error.message;
|
|
887
|
+
this.crawlerLog.endTime = new Date().toISOString();
|
|
888
|
+
this.lastRunTime = new Date().toISOString();
|
|
889
|
+
|
|
890
|
+
pckLog.error('Web crawler failed:', error);
|
|
891
|
+
throw error;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
startInitialCrawler() {
|
|
896
|
+
if (this.config.crawler.enabled) {
|
|
897
|
+
pckLog.info('Starting initial package crawler...');
|
|
898
|
+
|
|
899
|
+
// Run crawler in background (non-blocking)
|
|
900
|
+
setImmediate(async () => {
|
|
901
|
+
try {
|
|
902
|
+
await this.runCrawler();
|
|
903
|
+
pckLog.info('Initial package crawler completed successfully');
|
|
904
|
+
} catch (error) {
|
|
905
|
+
pckLog.error('Initial package crawler failed:', error.message);
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
setupRoutes() {
|
|
912
|
+
// Parameter validation configs
|
|
913
|
+
const searchParams = {
|
|
914
|
+
name: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ },
|
|
915
|
+
dependson: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ },
|
|
916
|
+
pkgcanonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*%?$/ },
|
|
917
|
+
canonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*$/ },
|
|
918
|
+
fhirversion: { maxLength: 10, pattern: /^(R2|R2B|R3|R4|R4B|R5|R6)?$/ },
|
|
919
|
+
dependency: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|-]*$/ },
|
|
920
|
+
sort: { maxLength: 20, pattern: /^-?(name|version|date|count|fhirversion|kind|canonical)$/ },
|
|
921
|
+
objWrapper: { maxLength: 10, pattern: /^(true|false)?$/ }
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
const updatesParams = {
|
|
925
|
+
dateType: { maxLength: 10, pattern: /^(relative|absolute)?$/, default: 'relative' },
|
|
926
|
+
daysValue: { maxLength: 3, pattern: /^\d{1,3}$/, default: '10' },
|
|
927
|
+
dateValue: { maxLength: 10, pattern: /^\d{4}-\d{2}-\d{2}$/, default: new Date().toISOString().split('T')[0] }
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// GET /packages/catalog - Search packages or get updates
|
|
931
|
+
this.router.get('/catalog', this.validateQueryParams(searchParams), async (req, res) => {
|
|
932
|
+
const start = Date.now();
|
|
933
|
+
try {
|
|
934
|
+
try {
|
|
935
|
+
await this.serveSearch(req, res);
|
|
936
|
+
pckLog.info("/catalog" + searchParams);
|
|
937
|
+
} catch (error) {
|
|
938
|
+
pckLog.error('Error in /packages/catalog:', error);
|
|
939
|
+
res.status(500).json({error: 'Internal server error'});
|
|
940
|
+
}
|
|
941
|
+
} finally {
|
|
942
|
+
this.stats.countRequest('catalog', Date.now() - start);
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// GET /packages/-/v1/search - Search packages (v1 API)
|
|
947
|
+
this.router.get('/-/v1/search', this.validateQueryParams(searchParams), async (req, res) => {
|
|
948
|
+
const start = Date.now();
|
|
949
|
+
try {
|
|
950
|
+
try {
|
|
951
|
+
req.query.objWrapper = 'true';
|
|
952
|
+
await this.serveSearch(req, res);
|
|
953
|
+
pckLog.info("/search?" + searchParams);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
pckLog.error('Error in /packages/-/v1/search:', error);
|
|
956
|
+
res.status(500).json({error: 'Internal server error'});
|
|
957
|
+
}
|
|
958
|
+
} finally {
|
|
959
|
+
this.stats.countRequest('search', Date.now() - start);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// GET /packages/updates
|
|
964
|
+
this.router.get('/updates', this.validateQueryParams(updatesParams), async (req, res) => {
|
|
965
|
+
const start = Date.now();
|
|
966
|
+
try {
|
|
967
|
+
try {
|
|
968
|
+
let {dateType, daysValue, dateValue} = req.query;
|
|
969
|
+
let dt = dateType || 'relative';
|
|
970
|
+
let days = daysValue || '10';
|
|
971
|
+
let date = dateValue || new Date().toISOString().split('T')[0];
|
|
972
|
+
await this.serveUpdates(req.secure, res, req, dt, days, date);
|
|
973
|
+
pckLog.info("/updates?" + searchParams);
|
|
974
|
+
} catch (error) {
|
|
975
|
+
pckLog.error('Error in /packages/updates:', error);
|
|
976
|
+
res.status(500).json({error: 'Internal server error'});
|
|
977
|
+
}
|
|
978
|
+
} finally {
|
|
979
|
+
this.stats.countRequest('updates', Date.now() - start);
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
this.router.get('/log', async (req, res) => {
|
|
984
|
+
const start = Date.now();
|
|
985
|
+
try {
|
|
986
|
+
try {
|
|
987
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
988
|
+
|
|
989
|
+
let logData;
|
|
990
|
+
let summary;
|
|
991
|
+
let status;
|
|
992
|
+
|
|
993
|
+
if (this.crawlerRunning) {
|
|
994
|
+
status = 'Crawler is currently running...';
|
|
995
|
+
logData = this.lastCrawlerLog || null;
|
|
996
|
+
} else if (this.lastCrawlerLog && this.lastCrawlerLog.feeds) {
|
|
997
|
+
status = 'Showing log from most recent crawler run';
|
|
998
|
+
logData = this.lastCrawlerLog;
|
|
999
|
+
|
|
1000
|
+
// Add summary statistics
|
|
1001
|
+
summary = {
|
|
1002
|
+
totalFeeds: this.lastCrawlerLog.feeds.length,
|
|
1003
|
+
successfulFeeds: this.lastCrawlerLog.feeds.filter(f => !f.exception && !f.rateLimited).length,
|
|
1004
|
+
failedFeeds: this.lastCrawlerLog.feeds.filter(f => f.exception && !f.rateLimited).length,
|
|
1005
|
+
rateLimitedFeeds: this.lastCrawlerLog.feeds.filter(f => f.rateLimited).length,
|
|
1006
|
+
totalItems: this.lastCrawlerLog.feeds.reduce((sum, f) => sum + (f.items ? f.items.length : 0), 0)
|
|
1007
|
+
};
|
|
1008
|
+
} else {
|
|
1009
|
+
status = 'No crawler runs have completed yet';
|
|
1010
|
+
logData = null;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (acceptsHtml) {
|
|
1014
|
+
const startTime = Date.now();
|
|
1015
|
+
|
|
1016
|
+
// Load template if not already loaded
|
|
1017
|
+
if (!htmlServer.hasTemplate('packages')) {
|
|
1018
|
+
const templatePath = path.join(__dirname, 'packages-template.html');
|
|
1019
|
+
htmlServer.loadTemplate('packages', templatePath);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const content = this.buildLogPageContent(status, logData, summary);
|
|
1023
|
+
const stats = await this.gatherPackageStatistics();
|
|
1024
|
+
stats.processingTime = Date.now() - startTime;
|
|
1025
|
+
|
|
1026
|
+
const html = htmlServer.renderPage('packages', 'Crawler Log', content, stats);
|
|
1027
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1028
|
+
res.send(html);
|
|
1029
|
+
} else {
|
|
1030
|
+
// Return JSON response
|
|
1031
|
+
const response = {
|
|
1032
|
+
status: status,
|
|
1033
|
+
crawlerRunning: this.crawlerRunning,
|
|
1034
|
+
log: logData,
|
|
1035
|
+
note: status
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
if (summary) {
|
|
1039
|
+
response.summary = summary;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
res.json(response);
|
|
1043
|
+
}
|
|
1044
|
+
pckLog.error("/log");
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
pckLog.error('Error in /packages/log:', error);
|
|
1047
|
+
if (req.headers.accept && req.headers.accept.includes('text/html')) {
|
|
1048
|
+
htmlServer.sendErrorResponse(res, 'packages', error);
|
|
1049
|
+
} else {
|
|
1050
|
+
res.status(500).json({error: 'Failed to get crawler log', message: error.message});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
} finally {
|
|
1054
|
+
this.stats.countRequest('log', Date.now() - start);
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// GET /packages/broken
|
|
1059
|
+
this.router.get('/broken', this.validateQueryParams({
|
|
1060
|
+
filter: { maxLength: 100, pattern: /^[a-zA-Z0-9._-]*$/ }
|
|
1061
|
+
}), async (req, res) => {
|
|
1062
|
+
const start = Date.now();
|
|
1063
|
+
try {
|
|
1064
|
+
try {
|
|
1065
|
+
const {filter} = req.query;
|
|
1066
|
+
await this.serveBroken(req, res, filter);
|
|
1067
|
+
pckLog.info("/broken");
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
pckLog.error('Error in /packages/broken:', error);
|
|
1070
|
+
res.status(500).json({error: 'Internal server error'});
|
|
1071
|
+
}
|
|
1072
|
+
} finally {
|
|
1073
|
+
this.stats.countRequest('broken', Date.now() - start);
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// GET /packages/:id/:version
|
|
1078
|
+
this.router.get('/:id/:version', (req, res, next) => {
|
|
1079
|
+
const start = Date.now();
|
|
1080
|
+
try {
|
|
1081
|
+
|
|
1082
|
+
// Validate path parameters
|
|
1083
|
+
const {id, version} = req.params;
|
|
1084
|
+
|
|
1085
|
+
if (!id || !version ||
|
|
1086
|
+
!/^[a-zA-Z0-9._-]+$/.test(id) ||
|
|
1087
|
+
!/^[a-zA-Z0-9._-]+$/.test(version)) {
|
|
1088
|
+
return res.status(400).json({error: 'Invalid package id or version format'});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (id.length > 100 || version.length > 50) {
|
|
1092
|
+
return res.status(400).json({error: 'Package id or version too long'});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
next();
|
|
1096
|
+
pckLog.info(`/download/${id}/${version}`);
|
|
1097
|
+
} finally {
|
|
1098
|
+
this.stats.countRequest('version', Date.now() - start);
|
|
1099
|
+
}
|
|
1100
|
+
}, async (req, res) => {
|
|
1101
|
+
const start = Date.now();
|
|
1102
|
+
try {
|
|
1103
|
+
try {
|
|
1104
|
+
const {id, version} = req.params;
|
|
1105
|
+
await this.serveDownload(req.secure, id, version, res);
|
|
1106
|
+
pckLog.info(`/download/${id}/${version}`);
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
pckLog.error('Error in /packages/:id/:version:', error);
|
|
1109
|
+
res.status(500).json({error: 'Internal server error'});
|
|
1110
|
+
}
|
|
1111
|
+
} finally {
|
|
1112
|
+
this.stats.countRequest('version', Date.now() - start);
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
// GET /packages/:page.html
|
|
1117
|
+
this.router.get('/:page.html', (req, res, next) => {
|
|
1118
|
+
const start = Date.now();
|
|
1119
|
+
try {
|
|
1120
|
+
|
|
1121
|
+
const {page} = req.params;
|
|
1122
|
+
|
|
1123
|
+
if (!page || !/^[a-zA-Z0-9_-]+$/.test(page) || page.length > 50) {
|
|
1124
|
+
return res.status(400).json({error: 'Invalid page name'});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
next();
|
|
1128
|
+
pckLog.info(`/page/${page}`);
|
|
1129
|
+
} finally {
|
|
1130
|
+
this.stats.countRequest('page', Date.now() - start);
|
|
1131
|
+
}
|
|
1132
|
+
}, async (req, res) => {
|
|
1133
|
+
const start = Date.now();
|
|
1134
|
+
try {
|
|
1135
|
+
try {
|
|
1136
|
+
const {page} = req.params;
|
|
1137
|
+
await this.servePage(`${page}.html`, req, res, req.secure);
|
|
1138
|
+
pckLog.info(`/page/${page}`);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
pckLog.error('Error in /packages/:page.html:', error);
|
|
1141
|
+
res.status(500).json({error: 'Internal server error'});
|
|
1142
|
+
}
|
|
1143
|
+
} finally {
|
|
1144
|
+
this.stats.countRequest('page', Date.now() - start);
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
// GET /packages/:id - Get package versions
|
|
1149
|
+
this.router.get('/:id', async (req, res) => {
|
|
1150
|
+
const start = Date.now();
|
|
1151
|
+
try {
|
|
1152
|
+
|
|
1153
|
+
try {
|
|
1154
|
+
const {id} = req.params;
|
|
1155
|
+
const {sort} = req.query;
|
|
1156
|
+
|
|
1157
|
+
// Don't process routes that are handled elsewhere
|
|
1158
|
+
if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates'].includes(id) ||
|
|
1159
|
+
id.endsWith('.html') || id === '-') {
|
|
1160
|
+
return; // Let other routes handle these
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
await this.serveVersions(id, sort, req.secure, req, res);
|
|
1164
|
+
pckLog.info(`/id/${id}`);
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
pckLog.error('Error in /packages/:id:', error);
|
|
1167
|
+
res.status(500).json({error: 'Internal server error'});
|
|
1168
|
+
}
|
|
1169
|
+
} finally {
|
|
1170
|
+
this.stats.countRequest('id', Date.now() - start);
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
// Main packages endpoint
|
|
1175
|
+
this.router.get('/', this.validateQueryParams(searchParams), async (req, res) => {
|
|
1176
|
+
const start = Date.now();
|
|
1177
|
+
try {
|
|
1178
|
+
|
|
1179
|
+
try {
|
|
1180
|
+
await this.serveSearch(req, res);
|
|
1181
|
+
pckLog.info(`/`);
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
pckLog.error('Error in /packages/:', error);
|
|
1184
|
+
res.status(500).json({error: 'Internal server error'});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
} finally {
|
|
1188
|
+
this.stats.countRequest('home', Date.now() - start);
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
// Module status endpoint (existing)
|
|
1193
|
+
this.router.get('/status', (req, res) => {
|
|
1194
|
+
const start = Date.now();
|
|
1195
|
+
try {
|
|
1196
|
+
const status = this.getStatus();
|
|
1197
|
+
res.json(status);
|
|
1198
|
+
pckLog.info('Serve Status');
|
|
1199
|
+
} finally {
|
|
1200
|
+
this.stats.countRequest('status', Date.now() - start);
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// Manual crawler trigger (existing)
|
|
1205
|
+
this.router.post('/crawl', async (req, res) => {
|
|
1206
|
+
const start = Date.now();
|
|
1207
|
+
try {
|
|
1208
|
+
try {
|
|
1209
|
+
await this.runCrawler();
|
|
1210
|
+
res.json({
|
|
1211
|
+
message: 'Crawler completed successfully',
|
|
1212
|
+
timestamp: new Date().toISOString()
|
|
1213
|
+
});
|
|
1214
|
+
pckLog.info('Serve Crawler');
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
pckLog.error('Manual crawler failed:', error);
|
|
1217
|
+
res.status(500).json({
|
|
1218
|
+
error: 'Crawler failed',
|
|
1219
|
+
message: error.message
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
} finally {
|
|
1223
|
+
this.stats.countRequest('crawl', Date.now() - start);
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
// Crawler statistics endpoint (existing)
|
|
1228
|
+
this.router.get('/stats', async (req, res) => {
|
|
1229
|
+
const start = Date.now();
|
|
1230
|
+
try {
|
|
1231
|
+
try {
|
|
1232
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
1233
|
+
|
|
1234
|
+
if (acceptsHtml) {
|
|
1235
|
+
const startTime = Date.now();
|
|
1236
|
+
|
|
1237
|
+
// Load template if not already loaded
|
|
1238
|
+
if (!htmlServer.hasTemplate('packages')) {
|
|
1239
|
+
const templatePath = path.join(__dirname, 'packages-template.html');
|
|
1240
|
+
htmlServer.loadTemplate('packages', templatePath);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const content = await this.buildStatsPageContent();
|
|
1244
|
+
const stats = await this.gatherPackageStatistics();
|
|
1245
|
+
stats.processingTime = Date.now() - startTime;
|
|
1246
|
+
|
|
1247
|
+
const html = htmlServer.renderPage('packages', 'Package Statistics', content, stats);
|
|
1248
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1249
|
+
res.send(html);
|
|
1250
|
+
} else {
|
|
1251
|
+
// JSON version (keep your existing logic)
|
|
1252
|
+
const dbCounts = await this.getDatabaseTableCounts();
|
|
1253
|
+
res.json({
|
|
1254
|
+
database: {
|
|
1255
|
+
packages: dbCounts.packages,
|
|
1256
|
+
versions: dbCounts.packageVersions
|
|
1257
|
+
},
|
|
1258
|
+
crawler: {
|
|
1259
|
+
enabled: this.config.crawler.enabled,
|
|
1260
|
+
schedule: this.config.crawler.schedule,
|
|
1261
|
+
lastRun: this.lastRunTime,
|
|
1262
|
+
totalRuns: this.totalRuns,
|
|
1263
|
+
lastLog: this.lastCrawlerLog || null
|
|
1264
|
+
},
|
|
1265
|
+
paths: {
|
|
1266
|
+
database: this.config.database,
|
|
1267
|
+
mirror: this.config.mirrorPath
|
|
1268
|
+
},
|
|
1269
|
+
config: {
|
|
1270
|
+
masterUrl: this.config.masterUrl
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
pckLog.info('Serve Stats');
|
|
1275
|
+
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
pckLog.error('Error generating stats:', error);
|
|
1278
|
+
if (req.headers.accept && req.headers.accept.includes('text/html')) {
|
|
1279
|
+
htmlServer.sendErrorResponse(res, 'packages', error);
|
|
1280
|
+
} else {
|
|
1281
|
+
res.status(500).json({error: 'Failed to generate stats', message: error.message});
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
} finally {
|
|
1285
|
+
this.stats.countRequest('stats', Date.now() - start);
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
// Search endpoint (existing)
|
|
1290
|
+
this.router.get('/search', async (req, res) => {
|
|
1291
|
+
const start = Date.now();
|
|
1292
|
+
try {
|
|
1293
|
+
|
|
1294
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
1295
|
+
|
|
1296
|
+
if (acceptsHtml) {
|
|
1297
|
+
try {
|
|
1298
|
+
const startTime = Date.now();
|
|
1299
|
+
|
|
1300
|
+
// Load template if not already loaded
|
|
1301
|
+
if (!htmlServer.hasTemplate('packages')) {
|
|
1302
|
+
const templatePath = path.join(__dirname, 'packages-template.html');
|
|
1303
|
+
htmlServer.loadTemplate('packages', templatePath);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const content = '<div class="alert alert-info"><h4>Search Coming Soon</h4><p>Package search functionality will be implemented here.</p></div>';
|
|
1307
|
+
const stats = await this.gatherPackageStatistics();
|
|
1308
|
+
stats.processingTime = Date.now() - startTime;
|
|
1309
|
+
|
|
1310
|
+
const html = htmlServer.renderPage('packages', 'Package Search', content, stats);
|
|
1311
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1312
|
+
res.send(html);
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
htmlServer.sendErrorResponse(res, 'packages', error);
|
|
1315
|
+
}
|
|
1316
|
+
} else {
|
|
1317
|
+
res.json({message: 'Package search functionality coming soon'});
|
|
1318
|
+
}
|
|
1319
|
+
} finally {
|
|
1320
|
+
this.stats.countRequest('search', Date.now() - start);
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
// Catch-all for unsupported operations (place this last)
|
|
1325
|
+
this.router.all('{*splat}', (req, res) => {
|
|
1326
|
+
const start = Date.now();
|
|
1327
|
+
try {
|
|
1328
|
+
res.status(404).json({
|
|
1329
|
+
error: `The operation ${req.method} ${req.path} is not supported`
|
|
1330
|
+
});
|
|
1331
|
+
} finally {
|
|
1332
|
+
this.stats.countRequest('*', Date.now() - start);
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// serveUpdates implementation with HTML support and form
|
|
1338
|
+
async serveUpdates(secure, res, req, dt, days, date) {
|
|
1339
|
+
try {
|
|
1340
|
+
let queryDate;
|
|
1341
|
+
|
|
1342
|
+
// Handle both number (days ago) and Date object
|
|
1343
|
+
if (dt === 'relative') {
|
|
1344
|
+
const daysAgo = parseInt(days) || 10;
|
|
1345
|
+
let qd = new Date();
|
|
1346
|
+
qd.setDate(qd.getDate() - daysAgo);
|
|
1347
|
+
queryDate = qd.toISOString().split('T')[0];
|
|
1348
|
+
} else {
|
|
1349
|
+
queryDate = date;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const updates = await this.getPackageUpdatesSince(queryDate);
|
|
1353
|
+
|
|
1354
|
+
const jsonArray = updates.map(row => ({
|
|
1355
|
+
name: row.Id,
|
|
1356
|
+
date: new Date(row.PubDate).toISOString(),
|
|
1357
|
+
version: row.Version,
|
|
1358
|
+
canonical: row.Canonical,
|
|
1359
|
+
fhirVersion: this.interpretVersion(row.FhirVersions),
|
|
1360
|
+
description: row.Description ? (
|
|
1361
|
+
Buffer.isBuffer(row.Description)
|
|
1362
|
+
? row.Description.toString('utf8')
|
|
1363
|
+
: row.Description
|
|
1364
|
+
) : undefined,
|
|
1365
|
+
kind: this.codeForKind(row.Kind),
|
|
1366
|
+
url: this.buildPackageUrl(row.Id, row.Version, secure, req)
|
|
1367
|
+
}));
|
|
1368
|
+
|
|
1369
|
+
// Check if client wants HTML response
|
|
1370
|
+
const acceptsHtml = req && req.headers.accept && req.headers.accept.includes('text/html');
|
|
1371
|
+
|
|
1372
|
+
if (acceptsHtml) {
|
|
1373
|
+
await this.returnUpdatesHtml(req, res, queryDate, jsonArray, secure, {
|
|
1374
|
+
dt,
|
|
1375
|
+
days, date
|
|
1376
|
+
});
|
|
1377
|
+
} else {
|
|
1378
|
+
// Return JSON response
|
|
1379
|
+
res.status(200);
|
|
1380
|
+
res.setHeader('Date', new Date().toUTCString());
|
|
1381
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1382
|
+
res.json(jsonArray);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
pckLog.error('Error in serveUpdates:', error);
|
|
1387
|
+
res.status(500).json({
|
|
1388
|
+
error: 'Failed to get package updates',
|
|
1389
|
+
message: error.message
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
async returnUpdatesHtml(req, res, fromDate, updates, secure, formData) {
|
|
1395
|
+
try {
|
|
1396
|
+
const startTime = Date.now();
|
|
1397
|
+
|
|
1398
|
+
// Load template if not already loaded
|
|
1399
|
+
if (!htmlServer.hasTemplate('packages')) {
|
|
1400
|
+
const templatePath = path.join(__dirname, 'packages-template.html');
|
|
1401
|
+
htmlServer.loadTemplate('packages', templatePath);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Build template variables
|
|
1405
|
+
const vars = {
|
|
1406
|
+
fromDate: fromDate.split('T')[0], // Just the date part
|
|
1407
|
+
fromDateTime: fromDate,
|
|
1408
|
+
count: updates.length,
|
|
1409
|
+
prefix: this.getAbsoluteUrl(secure),
|
|
1410
|
+
ver: '4.0.1',
|
|
1411
|
+
matches: this.generateUpdatesTable(updates, secure),
|
|
1412
|
+
status: 'Active',
|
|
1413
|
+
formData
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
// Generate updates page content
|
|
1417
|
+
const content = this.buildUpdatesPageContent(vars, fromDate, updates);
|
|
1418
|
+
const stats = await this.gatherPackageStatistics();
|
|
1419
|
+
stats.processingTime = Date.now() - startTime;
|
|
1420
|
+
|
|
1421
|
+
const title = `Package Updates since ${fromDate}`;
|
|
1422
|
+
const html = htmlServer.renderPage('packages', title, content, stats);
|
|
1423
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1424
|
+
res.send(html);
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
pckLog.error('Error rendering updates HTML:', error);
|
|
1427
|
+
htmlServer.sendErrorResponse(res, 'packages', error);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
generateUpdatesTable(updates) {
|
|
1432
|
+
if (updates.length === 0) {
|
|
1433
|
+
return '<div class="alert alert-info">No package updates found for the specified time period.</div>';
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
let table = '<div class="table-responsive"><table class="table table-striped">';
|
|
1437
|
+
table += '<thead><tr>';
|
|
1438
|
+
table += '<th>Package</th>';
|
|
1439
|
+
table += '<th>Version</th>';
|
|
1440
|
+
table += '<th>FHIR Version</th>';
|
|
1441
|
+
table += '<th>Type</th>';
|
|
1442
|
+
table += '<th>Published</th>';
|
|
1443
|
+
table += '<th>Canonical</th>';
|
|
1444
|
+
table += '</tr></thead><tbody>';
|
|
1445
|
+
|
|
1446
|
+
for (const pkg of updates) {
|
|
1447
|
+
table += '<tr>';
|
|
1448
|
+
table += `<td><a href="${this.escapeHtml(pkg.url)}">${this.escapeHtml(pkg.name)}</a></td>`;
|
|
1449
|
+
table += `<td>${this.escapeHtml(pkg.version)}</td>`;
|
|
1450
|
+
table += `<td>${this.escapeHtml(pkg.fhirVersion)}</td>`;
|
|
1451
|
+
table += `<td>${this.escapeHtml(pkg.kind)}</td>`;
|
|
1452
|
+
table += `<td>${new Date(pkg.date).toLocaleDateString()} ${new Date(pkg.date).toLocaleTimeString()}</td>`;
|
|
1453
|
+
table += `<td>${this.escapeHtml(pkg.canonical || '')}</td>`;
|
|
1454
|
+
table += '</tr>';
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
table += '</tbody></table></div>';
|
|
1458
|
+
return table;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
buildUpdatesPageContent(vars, fromDate, updates) {
|
|
1462
|
+
const formData = vars.formData;
|
|
1463
|
+
|
|
1464
|
+
let content = '<div class="row mb-4">';
|
|
1465
|
+
content += '<div class="col-12">';
|
|
1466
|
+
content += `<p>Showing packages updated since ${fromDate}</p>`;
|
|
1467
|
+
|
|
1468
|
+
content += '<form method="GET" action="/packages/updates">';
|
|
1469
|
+
|
|
1470
|
+
content += `<input type="radio" name="dateType" id="dateType" value="relative" ${formData.dt == 'relative' ? 'checked' : ''}> `;
|
|
1471
|
+
content += '<label for="relativeDays">Last</label> ';
|
|
1472
|
+
content += `<input type="number" name="daysValue" value="${formData.days}" min="1" max="365" style="width: 80px; margin: 0 5px;"> `;
|
|
1473
|
+
content += '<label>days</label> ';
|
|
1474
|
+
|
|
1475
|
+
content += `<input type="radio" name="dateType" id="dateType" value="absolute" ${formData.dt != 'relative' ? 'checked' : ''}> `;
|
|
1476
|
+
content += '<label for="specificDate">Since date:</label> ';
|
|
1477
|
+
content += `<input type="date" name="dateValue" value="${formData.date}" style="margin-left: 5px;"> `;
|
|
1478
|
+
content += '<button type="submit" class="btn btn-primary btn-sm" style="margin-left: 10px;">Update Results</button>';
|
|
1479
|
+
|
|
1480
|
+
content += '</form>';
|
|
1481
|
+
|
|
1482
|
+
// Summary info - now using the actual parameters
|
|
1483
|
+
content += '<table class="grid">';
|
|
1484
|
+
content += `<tr><td>Updates Found:</td><td>${updates.length}</td></tr>`;
|
|
1485
|
+
content += `<tr><td>Since Date:</td><td>${fromDate}</td></tr>`;
|
|
1486
|
+
content += `<tr><td>Query Time:</td><td>${new Date().toLocaleString()}</td></tr>`;
|
|
1487
|
+
content += '</table>';
|
|
1488
|
+
|
|
1489
|
+
// Updates table - now using the generateUpdatesTable method with actual updates
|
|
1490
|
+
content += this.generateUpdatesTable(updates);
|
|
1491
|
+
|
|
1492
|
+
content += '</div>';
|
|
1493
|
+
content += '</div>';
|
|
1494
|
+
|
|
1495
|
+
return content;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
async getPackageUpdatesSince(date) {
|
|
1499
|
+
return new Promise((resolve, reject) => {
|
|
1500
|
+
const sql = `SELECT Id, Version, PubDate, FhirVersions, Kind, Canonical, Description
|
|
1501
|
+
FROM PackageVersions
|
|
1502
|
+
WHERE PubDate >= ?
|
|
1503
|
+
ORDER BY PubDate DESC`;
|
|
1504
|
+
|
|
1505
|
+
this.db.all(sql, [date], (err, rows) => {
|
|
1506
|
+
if (err) {
|
|
1507
|
+
reject(err);
|
|
1508
|
+
} else {
|
|
1509
|
+
resolve(rows);
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
async serveDownload(secure, id, version, res) {
|
|
1516
|
+
try {
|
|
1517
|
+
// First try exact version match
|
|
1518
|
+
let packageData = await this.findPackageVersion(id, version, true);
|
|
1519
|
+
|
|
1520
|
+
// If not found, try fuzzy match (version + '-%' for pre-release versions)
|
|
1521
|
+
if (!packageData) {
|
|
1522
|
+
packageData = await this.findPackageVersion(id, version, false);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
if (!packageData) {
|
|
1526
|
+
// Package not found
|
|
1527
|
+
res.status(404);
|
|
1528
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
1529
|
+
res.send(`The package "${id}#${version}" is not known by this server`);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Check if we should redirect to bucket storage
|
|
1534
|
+
if (this.config.bucketPath) {
|
|
1535
|
+
let bucketUrl = this.getBucketUrl(secure);
|
|
1536
|
+
const redirectUrl = `${bucketUrl}${id}-${version}.tgz`;
|
|
1537
|
+
res.redirect(redirectUrl);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// Serve content directly from database
|
|
1542
|
+
await this.servePackageContent(packageData, id, version, res);
|
|
1543
|
+
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
pckLog.error('Error in serveDownload:', error);
|
|
1546
|
+
res.status(500).json({error: 'Download failed', message: error.message});
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
getBucketUrl(secure) {
|
|
1551
|
+
let bucketUrl = secure
|
|
1552
|
+
? this.config.bucketPath.replace('http:', 'https:')
|
|
1553
|
+
: this.config.bucketPath;
|
|
1554
|
+
if (!bucketUrl.endsWith('/')) {
|
|
1555
|
+
bucketUrl += '/';
|
|
1556
|
+
}
|
|
1557
|
+
return bucketUrl;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
async findPackageVersion(id, version, exactMatch) {
|
|
1561
|
+
return new Promise((resolve, reject) => {
|
|
1562
|
+
let sql;
|
|
1563
|
+
if (exactMatch) {
|
|
1564
|
+
sql = `SELECT PackageVersionKey, Content
|
|
1565
|
+
FROM PackageVersions
|
|
1566
|
+
WHERE Id = ?
|
|
1567
|
+
AND Version = ?`;
|
|
1568
|
+
} else {
|
|
1569
|
+
sql = `SELECT PackageVersionKey, Content
|
|
1570
|
+
FROM PackageVersions
|
|
1571
|
+
WHERE Id = ?
|
|
1572
|
+
AND Version LIKE ?
|
|
1573
|
+
ORDER BY PubDate DESC LIMIT 1`;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const params = exactMatch
|
|
1577
|
+
? [id, version]
|
|
1578
|
+
: [id, `${version}-%`];
|
|
1579
|
+
|
|
1580
|
+
this.db.get(sql, params, (err, row) => {
|
|
1581
|
+
if (err) {
|
|
1582
|
+
reject(err);
|
|
1583
|
+
} else {
|
|
1584
|
+
resolve(row || null);
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
async servePackageContent(packageData, id, version, res) {
|
|
1591
|
+
try {
|
|
1592
|
+
// Set response headers for file download
|
|
1593
|
+
res.status(200);
|
|
1594
|
+
res.setHeader('Content-Type', 'application/tar+gzip');
|
|
1595
|
+
res.setHeader('Content-Disposition', `attachment; filename="${id}#${version}.tgz"`);
|
|
1596
|
+
|
|
1597
|
+
// Convert BLOB content to Buffer if needed
|
|
1598
|
+
let contentBuffer;
|
|
1599
|
+
if (Buffer.isBuffer(packageData.Content)) {
|
|
1600
|
+
contentBuffer = packageData.Content;
|
|
1601
|
+
} else {
|
|
1602
|
+
// Handle case where Content might be stored differently
|
|
1603
|
+
contentBuffer = Buffer.from(packageData.Content);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Send the content
|
|
1607
|
+
res.send(contentBuffer);
|
|
1608
|
+
|
|
1609
|
+
// Update download counts after successful response
|
|
1610
|
+
// Do this asynchronously to not delay the response
|
|
1611
|
+
setImmediate(() => {
|
|
1612
|
+
this.incrementDownloadCounts(packageData.PackageVersionKey, id);
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
pckLog.error('Error serving package content:', error);
|
|
1617
|
+
throw error;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
async incrementDownloadCounts(packageVersionKey, packageId) {
|
|
1622
|
+
try {
|
|
1623
|
+
// Update PackageVersions download count
|
|
1624
|
+
await new Promise((resolve, reject) => {
|
|
1625
|
+
this.db.run(
|
|
1626
|
+
'UPDATE PackageVersions SET DownloadCount = DownloadCount + 1 WHERE PackageVersionKey = ?',
|
|
1627
|
+
[packageVersionKey],
|
|
1628
|
+
(err) => {
|
|
1629
|
+
if (err) reject(err);
|
|
1630
|
+
else resolve();
|
|
1631
|
+
}
|
|
1632
|
+
);
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
// Update Packages download count
|
|
1636
|
+
await new Promise((resolve, reject) => {
|
|
1637
|
+
this.db.run(
|
|
1638
|
+
'UPDATE Packages SET DownloadCount = DownloadCount + 1 WHERE Id = ?',
|
|
1639
|
+
[packageId],
|
|
1640
|
+
(err) => {
|
|
1641
|
+
if (err) reject(err);
|
|
1642
|
+
else resolve();
|
|
1643
|
+
}
|
|
1644
|
+
);
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
} catch (error) {
|
|
1648
|
+
pckLog.error('Error updating download counts:', error);
|
|
1649
|
+
// Don't throw here - download counts are not critical
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
async servePage(page, req, res) {
|
|
1654
|
+
// TODO: Implement page serving functionality
|
|
1655
|
+
res.json({
|
|
1656
|
+
message: 'Page serving not implemented yet',
|
|
1657
|
+
page
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
async serveVersions(id, sort, secure, req, res) {
|
|
1662
|
+
try {
|
|
1663
|
+
const packageVersions = await this.getPackageVersions(id);
|
|
1664
|
+
|
|
1665
|
+
if (packageVersions.length === 0) {
|
|
1666
|
+
res.status(404).json({error: `Package "${id}" not found`});
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Build npm-style registry response
|
|
1671
|
+
const registryResponse = await this.buildRegistryResponse(id, packageVersions, secure, req);
|
|
1672
|
+
|
|
1673
|
+
// Check if client wants HTML response
|
|
1674
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
1675
|
+
|
|
1676
|
+
if (acceptsHtml) {
|
|
1677
|
+
await this.returnVersionsHtml(req, res, id, packageVersions, registryResponse, secure, sort);
|
|
1678
|
+
} else {
|
|
1679
|
+
// Return JSON response in npm registry format
|
|
1680
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1681
|
+
res.json(registryResponse);
|
|
1682
|
+
}
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
pckLog.error('Error in serveVersions:', error);
|
|
1685
|
+
res.status(500).json({error: 'Failed to get package versions', message: error.message});
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
async getPackageVersions(id) {
|
|
1690
|
+
return new Promise((resolve, reject) => {
|
|
1691
|
+
const sql = `SELECT PackageVersionKey,
|
|
1692
|
+
Version,
|
|
1693
|
+
PubDate,
|
|
1694
|
+
FhirVersions,
|
|
1695
|
+
Canonical,
|
|
1696
|
+
DownloadCount,
|
|
1697
|
+
Kind,
|
|
1698
|
+
HomePage,
|
|
1699
|
+
Author,
|
|
1700
|
+
License,
|
|
1701
|
+
Hash,
|
|
1702
|
+
GUID,
|
|
1703
|
+
Description
|
|
1704
|
+
FROM PackageVersions
|
|
1705
|
+
WHERE Id = ?
|
|
1706
|
+
ORDER BY PubDate DESC`; // Changed from ASC to DESC for most recent first
|
|
1707
|
+
|
|
1708
|
+
this.db.all(sql, [id], (err, rows) => {
|
|
1709
|
+
if (err) {
|
|
1710
|
+
reject(err);
|
|
1711
|
+
} else {
|
|
1712
|
+
resolve(rows);
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
async getPackageDependencies(packageVersionKeys) {
|
|
1719
|
+
if (packageVersionKeys.length === 0) return {};
|
|
1720
|
+
|
|
1721
|
+
return new Promise((resolve, reject) => {
|
|
1722
|
+
const placeholders = packageVersionKeys.map(() => '?').join(',');
|
|
1723
|
+
const sql = `SELECT PackageVersionKey, Dependency
|
|
1724
|
+
FROM PackageDependencies
|
|
1725
|
+
WHERE PackageVersionKey IN (${placeholders})`;
|
|
1726
|
+
|
|
1727
|
+
this.db.all(sql, packageVersionKeys, (err, rows) => {
|
|
1728
|
+
if (err) {
|
|
1729
|
+
reject(err);
|
|
1730
|
+
} else {
|
|
1731
|
+
// Group dependencies by PackageVersionKey
|
|
1732
|
+
const deps = {};
|
|
1733
|
+
for (const row of rows) {
|
|
1734
|
+
if (!deps[row.PackageVersionKey]) {
|
|
1735
|
+
deps[row.PackageVersionKey] = {};
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const dependency = row.Dependency;
|
|
1739
|
+
const hashIndex = dependency.indexOf('#');
|
|
1740
|
+
if (hashIndex > 0) {
|
|
1741
|
+
const depName = dependency.substring(0, hashIndex);
|
|
1742
|
+
const depVersion = dependency.substring(hashIndex + 1);
|
|
1743
|
+
deps[row.PackageVersionKey][depName] = depVersion;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
resolve(deps);
|
|
1747
|
+
}
|
|
1748
|
+
});
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
async buildRegistryResponse(id, packageVersions, secure, req) {
|
|
1753
|
+
// Get all package version keys for dependency lookup
|
|
1754
|
+
const packageVersionKeys = packageVersions.map(pv => pv.PackageVersionKey);
|
|
1755
|
+
const dependencies = await this.getPackageDependencies(packageVersionKeys);
|
|
1756
|
+
|
|
1757
|
+
const registry = {
|
|
1758
|
+
_id: id,
|
|
1759
|
+
name: id,
|
|
1760
|
+
'dist-tags': {},
|
|
1761
|
+
versions: {}
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
let latestVersion = '';
|
|
1765
|
+
let description = '';
|
|
1766
|
+
|
|
1767
|
+
for (const [index, pv] of packageVersions.entries()) {
|
|
1768
|
+
// Latest version is the first one (ordered by PubDate DESC)
|
|
1769
|
+
if (index === 0) {
|
|
1770
|
+
latestVersion = pv.Version;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// Convert description BLOB to string
|
|
1774
|
+
if (pv.Description) {
|
|
1775
|
+
description = Buffer.isBuffer(pv.Description)
|
|
1776
|
+
? pv.Description.toString('utf8')
|
|
1777
|
+
: pv.Description;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
const versionObj = {
|
|
1781
|
+
name: id,
|
|
1782
|
+
_id: `${id}@${this.interpretVersion(pv.FhirVersions)}`,
|
|
1783
|
+
version: pv.Version,
|
|
1784
|
+
date: new Date(pv.PubDate).toISOString(),
|
|
1785
|
+
fhirVersion: this.interpretVersion(pv.FhirVersions),
|
|
1786
|
+
kind: this.codeForKind(pv.Kind),
|
|
1787
|
+
count: pv.DownloadCount || 0,
|
|
1788
|
+
canonical: pv.Canonical,
|
|
1789
|
+
url: this.buildPackageUrl(id, pv.Version, secure, req),
|
|
1790
|
+
dist: {
|
|
1791
|
+
shasum: pv.Hash,
|
|
1792
|
+
tarball: this.buildTarballUrl(id, pv.Version, secure, req)
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
|
|
1796
|
+
// Add optional fields
|
|
1797
|
+
if (pv.HomePage) {
|
|
1798
|
+
versionObj.homepage = pv.HomePage;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if (pv.License) {
|
|
1802
|
+
versionObj.license = pv.License;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
if (pv.Author) {
|
|
1806
|
+
versionObj.author = {name: pv.Author};
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
if (description) {
|
|
1810
|
+
versionObj.description = description;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// Add dependencies for this version
|
|
1814
|
+
if (dependencies[pv.PackageVersionKey]) {
|
|
1815
|
+
versionObj.dependencies = dependencies[pv.PackageVersionKey];
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
registry.versions[pv.Version] = versionObj;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Set latest version and description at package level
|
|
1822
|
+
registry['dist-tags'].latest = latestVersion;
|
|
1823
|
+
if (description) {
|
|
1824
|
+
registry.description = description;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
return registry;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
buildTarballUrl(id, version, secure, req) {
|
|
1831
|
+
if (this.config.bucketPath) {
|
|
1832
|
+
let bucketUrl = this.getBucketUrl(secure);
|
|
1833
|
+
return `${bucketUrl}${id}-${version}.tgz`;
|
|
1834
|
+
} else {
|
|
1835
|
+
// Use direct server URL
|
|
1836
|
+
const protocol = secure ? 'https' : 'http';
|
|
1837
|
+
const host = req.get('host') || 'localhost:3000';
|
|
1838
|
+
return `${protocol}://${host}/packages/${id}/${version}`;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
async returnVersionsHtml(req, res, id, packageVersions, registryResponse, secure, sort) {
|
|
1843
|
+
try {
|
|
1844
|
+
const startTime = Date.now();
|
|
1845
|
+
|
|
1846
|
+
// Load template if not already loaded
|
|
1847
|
+
if (!htmlServer.hasTemplate('packages')) {
|
|
1848
|
+
const templatePath = path.join(__dirname, 'packages-template.html');
|
|
1849
|
+
htmlServer.loadTemplate('packages', templatePath);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// Get package counts
|
|
1853
|
+
const versionCount = packageVersions.length;
|
|
1854
|
+
const totalDownloads = packageVersions.reduce((sum, pv) => sum + (pv.DownloadCount || 0), 0);
|
|
1855
|
+
|
|
1856
|
+
// Build template variables
|
|
1857
|
+
const vars = {
|
|
1858
|
+
name: id,
|
|
1859
|
+
desc: this.formatTextToHTML(registryResponse.description || ''),
|
|
1860
|
+
prefix: this.getAbsoluteUrl(false),
|
|
1861
|
+
ver: '4.0.1',
|
|
1862
|
+
matches: this.generateVersionsTable(packageVersions, id, secure, sort),
|
|
1863
|
+
status: 'Active',
|
|
1864
|
+
count: versionCount,
|
|
1865
|
+
downloads: totalDownloads
|
|
1866
|
+
};
|
|
1867
|
+
|
|
1868
|
+
// Generate versions page content
|
|
1869
|
+
const content = this.buildVersionsPageContent(vars, id);
|
|
1870
|
+
const stats = await this.gatherPackageStatistics();
|
|
1871
|
+
stats.processingTime = Date.now() - startTime;
|
|
1872
|
+
|
|
1873
|
+
const html = htmlServer.renderPage('packages', `Package Versions - ${id}`, content, stats);
|
|
1874
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1875
|
+
res.send(html);
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
pckLog.error('Error rendering versions HTML:', error);
|
|
1878
|
+
htmlServer.sendErrorResponse(res, 'packages', error);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
generateVersionsTable(packageVersions, id, secure, sort) {
|
|
1883
|
+
if (packageVersions.length === 0) {
|
|
1884
|
+
return '<div class="alert alert-info">No versions found for this package.</div>';
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Apply sorting if specified
|
|
1888
|
+
const sortedVersions = this.applySortingToVersions(packageVersions, sort);
|
|
1889
|
+
|
|
1890
|
+
let table = '<div class="table-responsive"><table class="table table-striped">';
|
|
1891
|
+
table += '<thead><tr>';
|
|
1892
|
+
table += '<th>Version</th>';
|
|
1893
|
+
table += '<th>FHIR Version</th>';
|
|
1894
|
+
table += '<th>Type</th>';
|
|
1895
|
+
table += '<th>Published</th>';
|
|
1896
|
+
table += '<th>Downloads</th>';
|
|
1897
|
+
table += '<th>Actions</th>';
|
|
1898
|
+
table += '</tr></thead><tbody>';
|
|
1899
|
+
|
|
1900
|
+
for (const pv of sortedVersions) {
|
|
1901
|
+
table += '<tr>';
|
|
1902
|
+
table += `<td title="${this.escapeHtml(pv.GUID)}"><strong>${this.escapeHtml(pv.Version)}</strong></td>`;
|
|
1903
|
+
table += `<td>${this.escapeHtml(this.interpretVersion(pv.FhirVersions))}</td>`;
|
|
1904
|
+
table += `<td>${this.escapeHtml(this.codeForKind(pv.Kind))}</td>`;
|
|
1905
|
+
table += `<td>${new Date(pv.PubDate).toLocaleDateString()}</td>`;
|
|
1906
|
+
table += `<td>${(pv.DownloadCount || 0).toLocaleString()}</td>`;
|
|
1907
|
+
table += `<td><a href="/packages/${this.escapeHtml(id)}/${this.escapeHtml(pv.Version)}" class="btn btn-sm btn-primary">Download</a></td>`;
|
|
1908
|
+
table += '</tr>';
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
table += '</tbody></table></div>';
|
|
1912
|
+
return table;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
applySortingToVersions(versions, sort) {
|
|
1916
|
+
if (!sort) return versions;
|
|
1917
|
+
|
|
1918
|
+
const descending = sort.startsWith('-');
|
|
1919
|
+
const sortField = descending ? sort.substring(1) : sort;
|
|
1920
|
+
|
|
1921
|
+
return [...versions].sort((a, b) => {
|
|
1922
|
+
let comparison = 0;
|
|
1923
|
+
|
|
1924
|
+
switch (sortField) {
|
|
1925
|
+
case 'version':
|
|
1926
|
+
comparison = this.compareVersions(a.Version, b.Version);
|
|
1927
|
+
break;
|
|
1928
|
+
case 'fhirversion':
|
|
1929
|
+
comparison = this.interpretVersion(a.FhirVersions).localeCompare(this.interpretVersion(b.FhirVersions));
|
|
1930
|
+
break;
|
|
1931
|
+
case 'kind':
|
|
1932
|
+
comparison = this.codeForKind(a.Kind).localeCompare(this.codeForKind(b.Kind));
|
|
1933
|
+
break;
|
|
1934
|
+
case 'date':
|
|
1935
|
+
comparison = new Date(a.PubDate) - new Date(b.PubDate);
|
|
1936
|
+
break;
|
|
1937
|
+
case 'count':
|
|
1938
|
+
comparison = (a.DownloadCount || 0) - (b.DownloadCount || 0);
|
|
1939
|
+
break;
|
|
1940
|
+
default:
|
|
1941
|
+
return 0;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
return descending ? -comparison : comparison;
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
buildVersionsPageContent(vars, id) {
|
|
1949
|
+
let content = '<div class="row mb-4">';
|
|
1950
|
+
content += '<div class="col-12">';
|
|
1951
|
+
content += '<table class="grid">';
|
|
1952
|
+
content += `<tr><td>Package ID:</td><td>${this.escapeHtml(id)}</td></tr>`;
|
|
1953
|
+
content += `<tr><td>Description</td><td>${vars.desc}</td></tr>`;
|
|
1954
|
+
content += `<tr><td>Total Versions:</td><td>${vars.count}</td></tr>`;
|
|
1955
|
+
content += `<tr><td>Total Downloads:</td><td>${vars.downloads}</td></tr>`;
|
|
1956
|
+
content += '</table>';
|
|
1957
|
+
|
|
1958
|
+
// Versions table
|
|
1959
|
+
content += '<div class="row">';
|
|
1960
|
+
content += '<div class="col-12">';
|
|
1961
|
+
content += '<h3>Available Versions</h3>';
|
|
1962
|
+
content += vars.matches;
|
|
1963
|
+
content += '</div>';
|
|
1964
|
+
content += '</div>';
|
|
1965
|
+
|
|
1966
|
+
return content;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
formatTextToHTML(text) {
|
|
1970
|
+
if (!text) return '';
|
|
1971
|
+
// Basic text to HTML formatting - convert newlines to <br>
|
|
1972
|
+
return this.escapeHtml(text).replace(/\n/g, '<br>');
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
async serveSearch(req, res) {
|
|
1976
|
+
const {
|
|
1977
|
+
name = '',
|
|
1978
|
+
dependson = '',
|
|
1979
|
+
pkgcanonical = '', // canonicalPkg in Pascal
|
|
1980
|
+
canonical = '', // canonicalUrl in Pascal
|
|
1981
|
+
fhirversion = '', // FHIRVersion in Pascal
|
|
1982
|
+
dependency = '',
|
|
1983
|
+
sort = '',
|
|
1984
|
+
objWrapper = false
|
|
1985
|
+
} = req.query;
|
|
1986
|
+
|
|
1987
|
+
const secure = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
1988
|
+
|
|
1989
|
+
try {
|
|
1990
|
+
const results = await this.searchPackages({
|
|
1991
|
+
name,
|
|
1992
|
+
dependson,
|
|
1993
|
+
canonicalPkg: pkgcanonical,
|
|
1994
|
+
canonicalUrl: canonical,
|
|
1995
|
+
fhirVersion: fhirversion,
|
|
1996
|
+
dependency,
|
|
1997
|
+
sort
|
|
1998
|
+
}, req, secure);
|
|
1999
|
+
|
|
2000
|
+
// Check if client wants HTML response
|
|
2001
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
2002
|
+
|
|
2003
|
+
if (acceptsHtml) {
|
|
2004
|
+
// Return HTML response using template
|
|
2005
|
+
await this.returnSearchHtml(req, res, {
|
|
2006
|
+
name,
|
|
2007
|
+
dependson,
|
|
2008
|
+
canonicalPkg: pkgcanonical,
|
|
2009
|
+
canonicalUrl: canonical,
|
|
2010
|
+
fhirVersion: fhirversion,
|
|
2011
|
+
sort
|
|
2012
|
+
}, results, secure);
|
|
2013
|
+
} else {
|
|
2014
|
+
// Return JSON response
|
|
2015
|
+
let responseData;
|
|
2016
|
+
|
|
2017
|
+
if (objWrapper) {
|
|
2018
|
+
// V1 API format with object wrapper
|
|
2019
|
+
responseData = {
|
|
2020
|
+
objects: results.map(pkg => ({package: pkg}))
|
|
2021
|
+
};
|
|
2022
|
+
} else {
|
|
2023
|
+
responseData = results;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2027
|
+
res.json(responseData);
|
|
2028
|
+
}
|
|
2029
|
+
} catch (error) {
|
|
2030
|
+
pckLog.error('Error in search:', error);
|
|
2031
|
+
res.status(500).json({error: 'Search failed', message: error.message});
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
async returnSearchHtml(req, res, searchParams, results, secure) {
|
|
2036
|
+
try {
|
|
2037
|
+
const startTime = Date.now();
|
|
2038
|
+
|
|
2039
|
+
// Load template if not already loaded
|
|
2040
|
+
if (!htmlServer.hasTemplate('packages')) {
|
|
2041
|
+
const templatePath = path.join(__dirname, 'packages-template.html');
|
|
2042
|
+
htmlServer.loadTemplate('packages', templatePath);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// Get total package count
|
|
2046
|
+
const packageCount = await this.getTotalPackageCount();
|
|
2047
|
+
const downloadCount = await this.getTotalDownloadCount();
|
|
2048
|
+
|
|
2049
|
+
// Build template variables
|
|
2050
|
+
const vars = {
|
|
2051
|
+
name: searchParams.name || '',
|
|
2052
|
+
dependson: searchParams.dependson || '',
|
|
2053
|
+
canonicalPkg: searchParams.canonicalPkg || '',
|
|
2054
|
+
canonicalUrl: searchParams.canonicalUrl || '',
|
|
2055
|
+
fhirVersion: searchParams.fhirVersion || '',
|
|
2056
|
+
sort: searchParams.sort || '',
|
|
2057
|
+
count: packageCount,
|
|
2058
|
+
prefix: this.getAbsoluteUrl(secure),
|
|
2059
|
+
ver: '4.0.1',
|
|
2060
|
+
r2selected: this.getSelected('R2', searchParams.fhirVersion),
|
|
2061
|
+
r3selected: this.getSelected('R3', searchParams.fhirVersion),
|
|
2062
|
+
r4selected: this.getSelected('R4', searchParams.fhirVersion),
|
|
2063
|
+
r5selected: this.getSelected('R5', searchParams.fhirVersion),
|
|
2064
|
+
matches: this.generateResultsTable(results, searchParams, secure),
|
|
2065
|
+
status: 'Active', // TODO: Get actual status
|
|
2066
|
+
downloads: downloadCount
|
|
2067
|
+
};
|
|
2068
|
+
|
|
2069
|
+
// Generate search page content
|
|
2070
|
+
const content = this.buildSearchPageContent(vars, results);
|
|
2071
|
+
const stats = await this.gatherPackageStatistics();
|
|
2072
|
+
stats.processingTime = Date.now() - startTime;
|
|
2073
|
+
|
|
2074
|
+
const html = htmlServer.renderPage('packages', 'Package Search', content, stats);
|
|
2075
|
+
res.setHeader('Content-Type', 'text/html');
|
|
2076
|
+
res.send(html);
|
|
2077
|
+
} catch (error) {
|
|
2078
|
+
pckLog.error('Error rendering search HTML:', error);
|
|
2079
|
+
htmlServer.sendErrorResponse(res, 'packages', error);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
async getTotalPackageCount() {
|
|
2084
|
+
return new Promise((resolve, reject) => {
|
|
2085
|
+
this.db.get('SELECT COUNT(*) as count FROM PackageVersions', [], (err, row) => {
|
|
2086
|
+
if (err) {
|
|
2087
|
+
reject(err);
|
|
2088
|
+
} else {
|
|
2089
|
+
resolve(row ? row.count : 0);
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
async getTotalDownloadCount() {
|
|
2096
|
+
return new Promise((resolve, reject) => {
|
|
2097
|
+
this.db.get('SELECT SUM(DownloadCount) as total FROM PackageVersions', [], (err, row) => {
|
|
2098
|
+
if (err) {
|
|
2099
|
+
reject(err);
|
|
2100
|
+
} else {
|
|
2101
|
+
resolve(row ? row.total || 0 : 0);
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
getAbsoluteUrl(secure) {
|
|
2108
|
+
const protocol = secure ? 'https:' : 'http:';
|
|
2109
|
+
return this.config.baseUrl || `${protocol}//localhost:${this.config.port || 3000}`;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
getSelected(value, current) {
|
|
2113
|
+
return value === current ? 'selected' : '';
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
generateResultsTable(results, searchParams) {
|
|
2117
|
+
if (results.length === 0) {
|
|
2118
|
+
return '<div class="alert alert-info">No packages found matching your search criteria.</div>';
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// Build base URL for sorting with current search parameters
|
|
2122
|
+
const baseUrl = '/packages/catalog?' + new URLSearchParams({
|
|
2123
|
+
...(searchParams.name && {name: searchParams.name}),
|
|
2124
|
+
...(searchParams.dependson && {dependson: searchParams.dependson}),
|
|
2125
|
+
...(searchParams.canonicalPkg && {pkgcanonical: searchParams.canonicalPkg}),
|
|
2126
|
+
...(searchParams.canonicalUrl && {canonical: searchParams.canonicalUrl}),
|
|
2127
|
+
...(searchParams.fhirVersion && {fhirversion: searchParams.fhirVersion})
|
|
2128
|
+
}).toString();
|
|
2129
|
+
|
|
2130
|
+
const currentSort = searchParams.sort || '';
|
|
2131
|
+
|
|
2132
|
+
let table = '<div class="table-responsive"><table class="table table-striped">';
|
|
2133
|
+
table += '<thead><tr>';
|
|
2134
|
+
table += `<th>${this.generateSortHeader('Package', 'name', baseUrl, currentSort)}</th>`;
|
|
2135
|
+
table += `<th>${this.generateSortHeader('Version', 'version', baseUrl, currentSort)}</th>`;
|
|
2136
|
+
table += `<th>${this.generateSortHeader('FHIR Version', 'fhirversion', baseUrl, currentSort)}</th>`;
|
|
2137
|
+
table += `<th>${this.generateSortHeader('Type', 'kind', baseUrl, currentSort)}</th>`;
|
|
2138
|
+
table += `<th>${this.generateSortHeader('Published', 'date', baseUrl, currentSort)}</th>`;
|
|
2139
|
+
table += `<th>${this.generateSortHeader('Downloads', 'count', baseUrl, currentSort)}</th>`;
|
|
2140
|
+
table += `<th>${this.generateSortHeader('Canonical', 'canonical', baseUrl, currentSort)}</th>`;
|
|
2141
|
+
table += '</tr></thead><tbody>';
|
|
2142
|
+
|
|
2143
|
+
for (const pkg of results) {
|
|
2144
|
+
table += '<tr>';
|
|
2145
|
+
table += `<td><a href="${this.escapeHtml(pkg.url)}">${this.escapeHtml(pkg.name)}</a></td>`;
|
|
2146
|
+
table += `<td>${this.escapeHtml(pkg.version)} (<a href="/packages/${this.escapeHtml(pkg.name)}">all</a>)</td>`;
|
|
2147
|
+
table += `<td>${this.escapeHtml(pkg.fhirVersion)}</td>`;
|
|
2148
|
+
table += `<td>${this.escapeHtml(pkg.kind)}</td>`;
|
|
2149
|
+
table += `<td>${pkg.date ? new Date(pkg.date).toLocaleDateString() : 'N/A'}</td>`;
|
|
2150
|
+
table += `<td>${pkg.count ? pkg.count.toLocaleString() : 'N/A'}</td>`;
|
|
2151
|
+
table += `<td>${this.escapeHtml(pkg.canonical || '')}</td>`;
|
|
2152
|
+
table += '</tr>';
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
table += '</tbody></table></div>';
|
|
2156
|
+
return table;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
generateSortHeader(title, field, baseUrl, currentSort) {
|
|
2160
|
+
const isCurrentField = currentSort === field || currentSort === `-${field}`;
|
|
2161
|
+
const isDescending = currentSort === `-${field}`;
|
|
2162
|
+
|
|
2163
|
+
// Determine next sort direction
|
|
2164
|
+
let nextSort;
|
|
2165
|
+
if (!isCurrentField) {
|
|
2166
|
+
nextSort = field; // Default to ascending
|
|
2167
|
+
} else if (!isDescending) {
|
|
2168
|
+
nextSort = `-${field}`; // Switch to descending
|
|
2169
|
+
} else {
|
|
2170
|
+
nextSort = field; // Switch back to ascending
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const sortUrl = `${baseUrl}&sort=${nextSort}`;
|
|
2174
|
+
|
|
2175
|
+
let header = `<a href="${sortUrl}" style="text-decoration: none; color: inherit;">${title}`;
|
|
2176
|
+
|
|
2177
|
+
if (isCurrentField) {
|
|
2178
|
+
if (isDescending) {
|
|
2179
|
+
header += ' <span style="color: #007bff;">▼</span>';
|
|
2180
|
+
} else {
|
|
2181
|
+
header += ' <span style="color: #007bff;">▲</span>';
|
|
2182
|
+
}
|
|
2183
|
+
} else {
|
|
2184
|
+
header += ' <span style="color: #ccc;">⇅</span>';
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
header += '</a>';
|
|
2188
|
+
|
|
2189
|
+
return header;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
buildSearchPageContent(vars, results) {
|
|
2193
|
+
let content = '<div class="row mb-4">';
|
|
2194
|
+
content += '<div class="col-12">';
|
|
2195
|
+
content += '</div>';
|
|
2196
|
+
content += '</div>';
|
|
2197
|
+
|
|
2198
|
+
// Search form - matching existing format exactly
|
|
2199
|
+
content += '<form method="GET" action="/packages/catalog">';
|
|
2200
|
+
content += '<table>';
|
|
2201
|
+
content += '<tbody>';
|
|
2202
|
+
|
|
2203
|
+
content += '<tr>';
|
|
2204
|
+
content += '<td>Id</td>';
|
|
2205
|
+
content += `<td><input type="text" name="name" value="${this.escapeHtml(vars.name)}"></td>`;
|
|
2206
|
+
content += '</tr>';
|
|
2207
|
+
|
|
2208
|
+
content += '<tr>';
|
|
2209
|
+
content += '<td>Depends On</td>';
|
|
2210
|
+
content += `<td><input type="text" name="dependson" value="${this.escapeHtml(vars.dependson)}"> <i>includes both direct and indirect dependencies</i></td>`;
|
|
2211
|
+
content += '</tr>';
|
|
2212
|
+
|
|
2213
|
+
content += '<tr>';
|
|
2214
|
+
content += '<td>Canonical (Package)</td>';
|
|
2215
|
+
content += `<td><input type="text" name="pkgcanonical" value="${this.escapeHtml(vars.canonicalPkg)}"></td>`;
|
|
2216
|
+
content += '</tr>';
|
|
2217
|
+
|
|
2218
|
+
content += '<tr>';
|
|
2219
|
+
content += '<td>Canonical (Resource)</td>';
|
|
2220
|
+
content += `<td><input type="text" name="canonical" value="${this.escapeHtml(vars.canonicalUrl)}"></td>`;
|
|
2221
|
+
content += '</tr>';
|
|
2222
|
+
|
|
2223
|
+
content += '<tr>';
|
|
2224
|
+
content += '<td>FHIR Version</td>';
|
|
2225
|
+
content += '<td><select name="fhirversion">';
|
|
2226
|
+
content += '<option value=""></option>';
|
|
2227
|
+
content += `<option value="R2" ${vars.r2selected}>R2</option>`;
|
|
2228
|
+
content += `<option value="R3" ${vars.r3selected}>R3</option>`;
|
|
2229
|
+
content += `<option value="R4" ${vars.r4selected}>R4</option>`;
|
|
2230
|
+
content += `<option value="R5" ${vars.r5selected}>R5</option>`;
|
|
2231
|
+
content += '</select></td>';
|
|
2232
|
+
content += '</tr>';
|
|
2233
|
+
|
|
2234
|
+
content += '</tbody>';
|
|
2235
|
+
content += '</table>';
|
|
2236
|
+
content += '<input type="submit" value="Search">';
|
|
2237
|
+
content += '</form>';
|
|
2238
|
+
|
|
2239
|
+
content += '<br><br>';
|
|
2240
|
+
|
|
2241
|
+
// Results
|
|
2242
|
+
content += `<h3>Results (${results.length} packages found)</h3>`;
|
|
2243
|
+
content += vars.matches;
|
|
2244
|
+
|
|
2245
|
+
return content;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
escapeSql(str) {
|
|
2249
|
+
if (!str) return '';
|
|
2250
|
+
return str.replace(/'/g, "''");
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
getVersion(fhirVersion) {
|
|
2254
|
+
// Map common FHIR version aliases to actual versions
|
|
2255
|
+
const versionMap = {
|
|
2256
|
+
'R2': '1.0.2',
|
|
2257
|
+
'R3': '3.0.2',
|
|
2258
|
+
'R4': '4.0.1',
|
|
2259
|
+
'R5': '5.0.0'
|
|
2260
|
+
};
|
|
2261
|
+
|
|
2262
|
+
return versionMap[fhirVersion] || fhirVersion;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
interpretVersion(fhirVersions) {
|
|
2266
|
+
if (!fhirVersions) return '';
|
|
2267
|
+
|
|
2268
|
+
// Handle comma-separated versions
|
|
2269
|
+
const versions = fhirVersions.split(',').map(v => v.trim());
|
|
2270
|
+
|
|
2271
|
+
// Return the primary version or join multiple versions
|
|
2272
|
+
return versions.length === 1 ? versions[0] : versions.join(', ');
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
codeForKind(kind) {
|
|
2276
|
+
const kindMap = {
|
|
2277
|
+
0: 'fhir.core',
|
|
2278
|
+
1: 'fhir.ig',
|
|
2279
|
+
2: 'fhir.template'
|
|
2280
|
+
};
|
|
2281
|
+
|
|
2282
|
+
return kindMap[kind] || 'fhir.ig';
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
buildPackageUrl(id, version, secure = false, req = null) {
|
|
2286
|
+
if (this.config.bucketPath) {
|
|
2287
|
+
let bucketUrl = this.getBucketUrl(secure);
|
|
2288
|
+
return `${bucketUrl}${id}-${version}.tgz`;
|
|
2289
|
+
} else {
|
|
2290
|
+
// Use direct server URL
|
|
2291
|
+
const protocol = secure ? 'https' : 'http';
|
|
2292
|
+
let host = 'localhost:3000';
|
|
2293
|
+
|
|
2294
|
+
if (req && req.get) {
|
|
2295
|
+
host = req.get('host') || 'localhost:3000';
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
const baseUrl = this.config.baseUrl || `${protocol}://${host}`;
|
|
2299
|
+
return `${baseUrl}/packages/${id}/${version}`;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
applySorting(results, sort) {
|
|
2304
|
+
if (!sort) return results;
|
|
2305
|
+
|
|
2306
|
+
const descending = sort.startsWith('-');
|
|
2307
|
+
const sortField = descending ? sort.substring(1) : sort;
|
|
2308
|
+
|
|
2309
|
+
return results.sort((a, b) => {
|
|
2310
|
+
let comparison = 0;
|
|
2311
|
+
|
|
2312
|
+
switch (sortField) {
|
|
2313
|
+
case 'name':
|
|
2314
|
+
comparison = a.name.localeCompare(b.name);
|
|
2315
|
+
break;
|
|
2316
|
+
case 'version':
|
|
2317
|
+
comparison = this.compareVersions(a.version, b.version);
|
|
2318
|
+
break;
|
|
2319
|
+
case 'date':
|
|
2320
|
+
comparison = new Date(a.date || 0) - new Date(b.date || 0);
|
|
2321
|
+
break;
|
|
2322
|
+
case 'count':
|
|
2323
|
+
comparison = (a.count || 0) - (b.count || 0);
|
|
2324
|
+
break;
|
|
2325
|
+
default:
|
|
2326
|
+
return 0;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
return descending ? -comparison : comparison;
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
compareVersions(a, b) {
|
|
2334
|
+
const aParts = a.split('.').map(Number);
|
|
2335
|
+
const bParts = b.split('.').map(Number);
|
|
2336
|
+
|
|
2337
|
+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
2338
|
+
const aPart = aParts[i] || 0;
|
|
2339
|
+
const bPart = bParts[i] || 0;
|
|
2340
|
+
|
|
2341
|
+
if (aPart !== bPart) {
|
|
2342
|
+
return aPart - bPart;
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
return 0;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
generateSearchHtml(req, results, params) {
|
|
2350
|
+
// Simplified HTML generation - you'd want to use a proper template engine
|
|
2351
|
+
const {name, dependson, canonicalPkg, canonicalUrl, fhirVersion} = params;
|
|
2352
|
+
return `
|
|
2353
|
+
<!DOCTYPE html>
|
|
2354
|
+
<html>
|
|
2355
|
+
<head>
|
|
2356
|
+
<title>FHIR Package Search</title>
|
|
2357
|
+
<style>
|
|
2358
|
+
body { font-family: Arial, sans-serif; margin: 20px; }
|
|
2359
|
+
.search-form { background: #f5f5f5; padding: 20px; margin-bottom: 20px; }
|
|
2360
|
+
.search-form input, .search-form select { margin: 5px; padding: 5px; }
|
|
2361
|
+
.results { margin-top: 20px; }
|
|
2362
|
+
.package { border: 1px solid #ddd; margin: 10px 0; padding: 15px; }
|
|
2363
|
+
.package-name { font-weight: bold; font-size: 1.2em; }
|
|
2364
|
+
.package-details { color: #666; margin-top: 5px; }
|
|
2365
|
+
</style>
|
|
2366
|
+
</head>
|
|
2367
|
+
<body>
|
|
2368
|
+
<h1>FHIR Package Search</h1>
|
|
2369
|
+
|
|
2370
|
+
<form class="search-form" method="GET">
|
|
2371
|
+
<input type="text" name="name" placeholder="Package name" value="${this.escapeHtml(name)}">
|
|
2372
|
+
<input type="text" name="dependson" placeholder="Depends on" value="${this.escapeHtml(dependson)}">
|
|
2373
|
+
<input type="text" name="canonicalPkg" placeholder="Canonical package" value="${this.escapeHtml(canonicalPkg)}">
|
|
2374
|
+
<input type="text" name="canonicalUrl" placeholder="Canonical URL" value="${this.escapeHtml(canonicalUrl)}">
|
|
2375
|
+
<select name="fhirVersion">
|
|
2376
|
+
<option value="">Any FHIR version</option>
|
|
2377
|
+
<option value="R2" ${fhirVersion === 'R2' ? 'selected' : ''}>R2</option>
|
|
2378
|
+
<option value="R3" ${fhirVersion === 'R3' ? 'selected' : ''}>R3</option>
|
|
2379
|
+
<option value="R4" ${fhirVersion === 'R4' ? 'selected' : ''}>R4</option>
|
|
2380
|
+
<option value="R5" ${fhirVersion === 'R5' ? 'selected' : ''}>R5</option>
|
|
2381
|
+
</select>
|
|
2382
|
+
<button type="submit">Search</button>
|
|
2383
|
+
</form>
|
|
2384
|
+
|
|
2385
|
+
<div class="results">
|
|
2386
|
+
<h2>Results (${results.length} packages found)</h2>
|
|
2387
|
+
${results.map(pkg => `
|
|
2388
|
+
<div class="package">
|
|
2389
|
+
<div class="package-name">
|
|
2390
|
+
<a href="${pkg.url}">${this.escapeHtml(pkg.name)}</a> v${this.escapeHtml(pkg.version)}
|
|
2391
|
+
</div>
|
|
2392
|
+
<div class="package-details">
|
|
2393
|
+
<strong>FHIR Version:</strong> ${this.escapeHtml(pkg.fhirVersion)}<br>
|
|
2394
|
+
<strong>Type:</strong> ${this.escapeHtml(pkg.kind)}<br>
|
|
2395
|
+
<strong>Canonical:</strong> ${this.escapeHtml(pkg.canonical)}<br>
|
|
2396
|
+
${pkg.description ? `<strong>Description:</strong> ${this.escapeHtml(pkg.description)}<br>` : ''}
|
|
2397
|
+
${pkg.date ? `<strong>Published:</strong> ${new Date(pkg.date).toLocaleDateString()}<br>` : ''}
|
|
2398
|
+
${pkg.count ? `<strong>Downloads:</strong> ${pkg.count}<br>` : ''}
|
|
2399
|
+
</div>
|
|
2400
|
+
</div>
|
|
2401
|
+
`).join('')}
|
|
2402
|
+
</div>
|
|
2403
|
+
</body>
|
|
2404
|
+
</html>
|
|
2405
|
+
`;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
async shutdown() {
|
|
2409
|
+
pckLog.info('Shutting down Packages module...');
|
|
2410
|
+
|
|
2411
|
+
this.stopCrawlerJob();
|
|
2412
|
+
|
|
2413
|
+
// Close database connection
|
|
2414
|
+
if (this.db) {
|
|
2415
|
+
return new Promise((resolve) => {
|
|
2416
|
+
this.db.close((err) => {
|
|
2417
|
+
if (err) {
|
|
2418
|
+
pckLog.error('Error closing packages database:', err.message);
|
|
2419
|
+
} else {
|
|
2420
|
+
pckLog.info('Packages database connection closed');
|
|
2421
|
+
}
|
|
2422
|
+
resolve();
|
|
2423
|
+
});
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
pckLog.info('Packages module shut down');
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
async serveBroken(req, res, filter) {
|
|
2431
|
+
try {
|
|
2432
|
+
// Build list of valid package references (Id#MajorMinorVersion)
|
|
2433
|
+
const validPackages = await this.getValidPackageReferences();
|
|
2434
|
+
|
|
2435
|
+
// Find broken dependencies
|
|
2436
|
+
const brokenDependencies = await this.findBrokenDependencies(validPackages, filter);
|
|
2437
|
+
|
|
2438
|
+
// Check if client wants HTML response
|
|
2439
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
2440
|
+
|
|
2441
|
+
if (acceptsHtml) {
|
|
2442
|
+
await this.returnBrokenHtml(req, res, brokenDependencies, filter);
|
|
2443
|
+
} else {
|
|
2444
|
+
// Return JSON response
|
|
2445
|
+
const jsonResponse = {
|
|
2446
|
+
...brokenDependencies,
|
|
2447
|
+
date: new Date().toISOString()
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2450
|
+
res.status(200);
|
|
2451
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2452
|
+
res.json(jsonResponse);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
} catch (error) {
|
|
2456
|
+
pckLog.error('Error in serveBroken:', error);
|
|
2457
|
+
res.status(500).json({
|
|
2458
|
+
error: 'Failed to generate broken dependencies report',
|
|
2459
|
+
message: error.message
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
async getValidPackageReferences() {
|
|
2465
|
+
return new Promise((resolve, reject) => {
|
|
2466
|
+
const sql = 'SELECT Id, Version FROM PackageVersions';
|
|
2467
|
+
|
|
2468
|
+
this.db.all(sql, [], (err, rows) => {
|
|
2469
|
+
if (err) {
|
|
2470
|
+
reject(err);
|
|
2471
|
+
} else {
|
|
2472
|
+
const validPackages = new Set();
|
|
2473
|
+
|
|
2474
|
+
for (const row of rows) {
|
|
2475
|
+
// Create reference in format: Id#MajorMinorVersion
|
|
2476
|
+
const majorMinorVersion = this.getMajorMinorVersion(row.Version);
|
|
2477
|
+
const packageRef = `${row.Id}#${majorMinorVersion}`;
|
|
2478
|
+
validPackages.add(packageRef);
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
resolve(validPackages);
|
|
2482
|
+
}
|
|
2483
|
+
});
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
async findBrokenDependencies(validPackages, filter) {
|
|
2488
|
+
return new Promise((resolve, reject) => {
|
|
2489
|
+
const sql = `SELECT PackageVersions.Id || '#' || PackageVersions.Version as Source,
|
|
2490
|
+
PackageDependencies.Dependency
|
|
2491
|
+
FROM PackageDependencies,
|
|
2492
|
+
PackageVersions
|
|
2493
|
+
WHERE PackageDependencies.PackageVersionKey = PackageVersions.PackageVersionKey`;
|
|
2494
|
+
|
|
2495
|
+
this.db.all(sql, [], (err, rows) => {
|
|
2496
|
+
if (err) {
|
|
2497
|
+
reject(err);
|
|
2498
|
+
} else {
|
|
2499
|
+
const brokenDeps = {};
|
|
2500
|
+
|
|
2501
|
+
for (const row of rows) {
|
|
2502
|
+
const source = row.Source;
|
|
2503
|
+
const dependency = row.Dependency;
|
|
2504
|
+
|
|
2505
|
+
// Apply filter if specified
|
|
2506
|
+
if (filter && !source.includes(filter)) {
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// Extract dependency name and version
|
|
2511
|
+
const hashIndex = dependency.indexOf('#');
|
|
2512
|
+
if (hashIndex > 0) {
|
|
2513
|
+
const depName = dependency.substring(0, hashIndex);
|
|
2514
|
+
const depVersion = dependency.substring(hashIndex + 1);
|
|
2515
|
+
const depMajorMinor = this.getMajorMinorVersion(depVersion);
|
|
2516
|
+
const depRef = `${depName}#${depMajorMinor}`;
|
|
2517
|
+
|
|
2518
|
+
// Check if this dependency exists in valid packages
|
|
2519
|
+
if (!validPackages.has(depRef)) {
|
|
2520
|
+
// This is a broken dependency
|
|
2521
|
+
if (!brokenDeps[source]) {
|
|
2522
|
+
brokenDeps[source] = [];
|
|
2523
|
+
}
|
|
2524
|
+
brokenDeps[source].push(dependency);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
resolve(brokenDeps);
|
|
2530
|
+
}
|
|
2531
|
+
});
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
getMajorMinorVersion(version) {
|
|
2536
|
+
// Extract major.minor from version string (e.g., "1.0.0" -> "1.0", "2.1.3-beta" -> "2.1")
|
|
2537
|
+
if (!version) return version;
|
|
2538
|
+
|
|
2539
|
+
const parts = version.split('.');
|
|
2540
|
+
if (parts.length >= 2) {
|
|
2541
|
+
// Handle pre-release versions by taking only the numeric part
|
|
2542
|
+
const minor = parts[1].replace(/[^0-9].*/g, '');
|
|
2543
|
+
return `${parts[0]}.${minor}`;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
return version;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
async returnBrokenHtml(req, res, brokenDependencies, filter) {
|
|
2550
|
+
try {
|
|
2551
|
+
const startTime = Date.now();
|
|
2552
|
+
|
|
2553
|
+
// Load template if not already loaded
|
|
2554
|
+
if (!htmlServer.hasTemplate('packages')) {
|
|
2555
|
+
const templatePath = path.join(__dirname, 'packages-template.html');
|
|
2556
|
+
htmlServer.loadTemplate('packages', templatePath);
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// Build template variables
|
|
2560
|
+
const vars = {
|
|
2561
|
+
prefix: this.getAbsoluteUrl(false),
|
|
2562
|
+
ver: '4.0.1',
|
|
2563
|
+
filter: this.formatTextToHTML(filter || ''),
|
|
2564
|
+
table: this.generateBrokenTable(brokenDependencies),
|
|
2565
|
+
status: 'Active'
|
|
2566
|
+
};
|
|
2567
|
+
|
|
2568
|
+
// Generate broken dependencies page content
|
|
2569
|
+
const content = this.buildBrokenPageContent(vars, brokenDependencies, filter);
|
|
2570
|
+
const stats = await this.gatherPackageStatistics();
|
|
2571
|
+
stats.processingTime = Date.now() - startTime;
|
|
2572
|
+
|
|
2573
|
+
const title = `Broken Package Dependencies${filter ? ` (filtered: ${filter})` : ''}`;
|
|
2574
|
+
const html = htmlServer.renderPage('packages', title, content, stats);
|
|
2575
|
+
res.setHeader('Content-Type', 'text/html');
|
|
2576
|
+
res.send(html);
|
|
2577
|
+
} catch (error) {
|
|
2578
|
+
pckLog.error('Error rendering broken dependencies HTML:', error);
|
|
2579
|
+
htmlServer.sendErrorResponse(res, 'packages', error);
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
generateBrokenTable(brokenDependencies) {
|
|
2584
|
+
const sourcePackages = Object.keys(brokenDependencies).sort();
|
|
2585
|
+
|
|
2586
|
+
if (sourcePackages.length === 0) {
|
|
2587
|
+
return '<div class="alert alert-success">No broken dependencies found! All package dependencies are satisfied.</div>';
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
let table = '<table class="grid">';
|
|
2591
|
+
table += '<tr><td><b>Source Package</b></td><td><b>Broken Dependencies</b></td></tr>';
|
|
2592
|
+
|
|
2593
|
+
for (const source of sourcePackages) {
|
|
2594
|
+
const dependencies = brokenDependencies[source];
|
|
2595
|
+
table += '<tr>';
|
|
2596
|
+
table += `<td>${this.escapeHtml(source)}</td>`;
|
|
2597
|
+
table += '<td>';
|
|
2598
|
+
|
|
2599
|
+
for (let i = 0; i < dependencies.length; i++) {
|
|
2600
|
+
if (i > 0) {
|
|
2601
|
+
table += ', ';
|
|
2602
|
+
}
|
|
2603
|
+
table += this.escapeHtml(dependencies[i]);
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
table += '</td>';
|
|
2607
|
+
table += '</tr>';
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
table += '</table>';
|
|
2611
|
+
return table;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
buildBrokenPageContent(vars, brokenDependencies) {
|
|
2615
|
+
const affectedCount = Object.keys(brokenDependencies).length;
|
|
2616
|
+
const totalBrokenDeps = Object.values(brokenDependencies).reduce((sum, deps) => sum + deps.length, 0);
|
|
2617
|
+
|
|
2618
|
+
let content = '<div class="row mb-4">';
|
|
2619
|
+
content += '<div class="col-12">';
|
|
2620
|
+
content += '<p>Packages that reference dependencies which cannot be resolved</p>';
|
|
2621
|
+
|
|
2622
|
+
// Summary info
|
|
2623
|
+
content += '<table class="grid">';
|
|
2624
|
+
content += `<tr><td>Affected Packages:</td><td>${affectedCount}</td></tr>`;
|
|
2625
|
+
content += `<tr><td>Total Broken Dependencies:</td><td>${totalBrokenDeps}</td></tr>`;
|
|
2626
|
+
content += `<tr><td>Report Generated:</td><td>${new Date().toLocaleString()}</td></tr>`;
|
|
2627
|
+
content += '</table>';
|
|
2628
|
+
|
|
2629
|
+
// Help info
|
|
2630
|
+
content += '<h5>About This Report</h5>';
|
|
2631
|
+
content += '<p>This report shows packages that have dependencies which cannot be resolved. A dependency is considered broken if:</p>';
|
|
2632
|
+
content += '<ul>';
|
|
2633
|
+
content += '<li>The referenced package does not exist</li>';
|
|
2634
|
+
content += '<li>The referenced version (major.minor) is not available</li>';
|
|
2635
|
+
content += '</ul>';
|
|
2636
|
+
content += '<p><small><strong>Note:</strong> Version matching uses major.minor comparison (e.g., 1.0.0 matches 1.0.x)</small></p>';
|
|
2637
|
+
|
|
2638
|
+
// Results table
|
|
2639
|
+
content += '<div class="row">';
|
|
2640
|
+
content += '<div class="col-12">';
|
|
2641
|
+
if (affectedCount > 0) {
|
|
2642
|
+
content += '<h3>Broken Dependencies</h3>';
|
|
2643
|
+
content += vars.table;
|
|
2644
|
+
} else {
|
|
2645
|
+
content += '<div class="alert alert-success">';
|
|
2646
|
+
content += '<h4>✅ No Broken Dependencies Found</h4>';
|
|
2647
|
+
content += '<p>All package dependencies are properly resolved!</p>';
|
|
2648
|
+
content += '</div>';
|
|
2649
|
+
}
|
|
2650
|
+
content += '</div>';
|
|
2651
|
+
content += '</div>';
|
|
2652
|
+
|
|
2653
|
+
return content;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
buildLogPageContent(status, logData, summary) {
|
|
2657
|
+
let content = '<div class="row mb-4">';
|
|
2658
|
+
content += '<div class="col-12">';
|
|
2659
|
+
content += `<div class="alert ${this.crawlerRunning ? 'alert-info' : 'alert-secondary'}">${this.escapeHtml(status)}</div>`;
|
|
2660
|
+
content += '</div>';
|
|
2661
|
+
content += '</div>';
|
|
2662
|
+
|
|
2663
|
+
if (this.crawlerRunning) {
|
|
2664
|
+
content += '<div class="row mb-4">';
|
|
2665
|
+
content += '<div class="col-12">';
|
|
2666
|
+
content += '<div class="spinner-border text-primary" role="status">';
|
|
2667
|
+
content += '<span class="sr-only">Loading...</span>';
|
|
2668
|
+
content += '</div>';
|
|
2669
|
+
content += ' <strong>Refresh this page in a few minutes to see updated status.</strong>';
|
|
2670
|
+
content += '</div>';
|
|
2671
|
+
content += '</div>';
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
if (summary || logData) {
|
|
2675
|
+
content += '<table class="table table-sm">';
|
|
2676
|
+
|
|
2677
|
+
if (logData) {
|
|
2678
|
+
if (logData.startTime) {
|
|
2679
|
+
content += `<tr><td>Start Time:</td><td>${new Date(logData.startTime).toLocaleString()}</td></tr>`;
|
|
2680
|
+
}
|
|
2681
|
+
if (logData.endTime) {
|
|
2682
|
+
content += `<tr><td>End Time:</td><td>${new Date(logData.endTime).toLocaleString()}</td></tr>`;
|
|
2683
|
+
}
|
|
2684
|
+
if (logData.runTime) {
|
|
2685
|
+
content += `<tr><td>Duration:</td><td>${logData.runTime}</td></tr>`;
|
|
2686
|
+
}
|
|
2687
|
+
if (logData.totalBytes) {
|
|
2688
|
+
content += `<tr><td>Total Bytes:</td><td>${logData.totalBytes.toLocaleString()}</td></tr>`;
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
if (summary) {
|
|
2693
|
+
content += `<tr><td>Total Feeds:</td><td>${summary.totalFeeds}</td></tr>`;
|
|
2694
|
+
content += `<tr><td>Successful Feeds:</td><td class="text-success">${summary.successfulFeeds}</td></tr>`;
|
|
2695
|
+
content += `<tr><td>Failed Feeds:</td><td class="text-danger">${summary.failedFeeds}</td></tr>`;
|
|
2696
|
+
content += `<tr><td>Rate Limited Feeds:</td><td class="text-warning">${summary.rateLimitedFeeds}</td></tr>`;
|
|
2697
|
+
content += `<tr><td>Total Items Processed:</td><td>${summary.totalItems}</td></tr>`;
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
content += '</table>';
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
if (logData) {
|
|
2704
|
+
content += '<h3>Crawler Log</h3>';
|
|
2705
|
+
content += '<pre style="background-color: #f8f9fa; padding: 15px; border-radius: 5px; font-family: monospace; white-space: pre-wrap;">';
|
|
2706
|
+
content += this.formatCrawlerLog(logData);
|
|
2707
|
+
content += '</pre>';
|
|
2708
|
+
} else if (!this.crawlerRunning) {
|
|
2709
|
+
content += '<div class="alert alert-info">';
|
|
2710
|
+
content += '<h4>No Log Data Available</h4>';
|
|
2711
|
+
content += '<p>The crawler hasn\'t run yet or the log data is not available.</p>';
|
|
2712
|
+
if (this.config.crawler.enabled) {
|
|
2713
|
+
content += '<p><button onclick="triggerCrawl()" class="btn btn-primary">Start Manual Crawl</button></p>';
|
|
2714
|
+
}
|
|
2715
|
+
content += '</div>';
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
return content;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// Add this new method to format the crawler log as readable text
|
|
2722
|
+
formatCrawlerLog(logData) {
|
|
2723
|
+
let output = '';
|
|
2724
|
+
|
|
2725
|
+
if (logData.fatalException) {
|
|
2726
|
+
output += `FATAL ERROR: ${logData.fatalException}\n\n`;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
if (logData.feeds && logData.feeds.length > 0) {
|
|
2730
|
+
for (const feed of logData.feeds) {
|
|
2731
|
+
if (feed.exception || feed.rateLimited) {
|
|
2732
|
+
// Feed itself had an error
|
|
2733
|
+
const error = feed.rateLimited ? feed.rateLimitMessage : feed.exception;
|
|
2734
|
+
output += `Feed: ${feed.url}: ${error}\n`;
|
|
2735
|
+
} else {
|
|
2736
|
+
// Feed was successful
|
|
2737
|
+
output += `Feed: ${feed.url}: ok\n`;
|
|
2738
|
+
|
|
2739
|
+
// Show any item errors
|
|
2740
|
+
if (feed.items && feed.items.length > 0) {
|
|
2741
|
+
for (const item of feed.items) {
|
|
2742
|
+
if (item.error && item.status !== 'Already Processed') {
|
|
2743
|
+
const guid = item.guid || 'unknown';
|
|
2744
|
+
output += ` error: ${guid}: ${item.error}\n`;
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
output += '\n';
|
|
2750
|
+
}
|
|
2751
|
+
} else {
|
|
2752
|
+
output += 'No feeds processed.\n';
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
return this.escapeHtml(output);
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
getStatus() {
|
|
2759
|
+
return {
|
|
2760
|
+
enabled: true,
|
|
2761
|
+
database: {
|
|
2762
|
+
connected: this.db ? true : false,
|
|
2763
|
+
path: this.config.database
|
|
2764
|
+
},
|
|
2765
|
+
mirror: {
|
|
2766
|
+
path: this.config.mirrorPath,
|
|
2767
|
+
exists: fs.existsSync(this.config.mirrorPath)
|
|
2768
|
+
},
|
|
2769
|
+
crawler: {
|
|
2770
|
+
enabled: this.config.crawler.enabled,
|
|
2771
|
+
running: this.crawlerJob ? true : false,
|
|
2772
|
+
schedule: this.config.crawler.schedule,
|
|
2773
|
+
lastRun: this.lastRunTime,
|
|
2774
|
+
totalRuns: this.totalRuns
|
|
2775
|
+
}
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
async buildStatsPageContent() {
|
|
2780
|
+
const dbCounts = await this.getDatabaseTableCounts();
|
|
2781
|
+
const dbAge = this.getDatabaseAgeInfo();
|
|
2782
|
+
|
|
2783
|
+
let content = '<div class="row mb-4">';
|
|
2784
|
+
content += '<div class="col-12"><h2>Package Database Statistics</h2></div>';
|
|
2785
|
+
content += '</div>';
|
|
2786
|
+
|
|
2787
|
+
content += '<div class="row mb-4">';
|
|
2788
|
+
content += '<div class="col-md-4">';
|
|
2789
|
+
content += '<div class="card text-center">';
|
|
2790
|
+
content += '<div class="card-body">';
|
|
2791
|
+
content += '<h5 class="card-title">Total Packages</h5>';
|
|
2792
|
+
content += `<h2 class="text-primary">${dbCounts.packages.toLocaleString()}</h2>`;
|
|
2793
|
+
content += '</div>';
|
|
2794
|
+
content += '</div>';
|
|
2795
|
+
content += '</div>';
|
|
2796
|
+
|
|
2797
|
+
content += '<div class="col-md-4">';
|
|
2798
|
+
content += '<div class="card text-center">';
|
|
2799
|
+
content += '<div class="card-body">';
|
|
2800
|
+
content += '<h5 class="card-title">Package Versions</h5>';
|
|
2801
|
+
content += `<h2 class="text-success">${dbCounts.packageVersions.toLocaleString()}</h2>`;
|
|
2802
|
+
content += '</div>';
|
|
2803
|
+
content += '</div>';
|
|
2804
|
+
content += '</div>';
|
|
2805
|
+
|
|
2806
|
+
content += '<div class="col-md-4">';
|
|
2807
|
+
content += '<div class="card text-center">';
|
|
2808
|
+
content += '<div class="card-body">';
|
|
2809
|
+
content += '<h5 class="card-title">Database Age</h5>';
|
|
2810
|
+
content += `<h2 class="text-info">${dbAge.status}</h2>`;
|
|
2811
|
+
content += '</div>';
|
|
2812
|
+
content += '</div>';
|
|
2813
|
+
content += '</div>';
|
|
2814
|
+
content += '</div>';
|
|
2815
|
+
|
|
2816
|
+
// Crawler statistics
|
|
2817
|
+
content += '<div class="row">';
|
|
2818
|
+
content += '<div class="col-12">';
|
|
2819
|
+
content += '<h3>Crawler Statistics</h3>';
|
|
2820
|
+
content += '<table class="table table-striped">';
|
|
2821
|
+
content += '<tr><th>Metric</th><th>Value</th></tr>';
|
|
2822
|
+
content += `<tr><td>Crawler Status</td><td>${this.config.crawler.enabled ? 'Enabled' : 'Disabled'}</td></tr>`;
|
|
2823
|
+
content += `<tr><td>Schedule</td><td>${this.config.crawler.schedule || 'Not scheduled'}</td></tr>`;
|
|
2824
|
+
content += `<tr><td>Total Runs</td><td>${this.totalRuns}</td></tr>`;
|
|
2825
|
+
if (this.lastRunTime) {
|
|
2826
|
+
content += `<tr><td>Last Run</td><td>${new Date(this.lastRunTime).toLocaleString()}</td></tr>`;
|
|
2827
|
+
}
|
|
2828
|
+
content += `<tr><td>Master URL</td><td><a href="${htmlServer.escapeHtml(this.config.masterUrl)}" target="_blank">${htmlServer.escapeHtml(this.config.masterUrl)}</a></td></tr>`;
|
|
2829
|
+
content += '</table>';
|
|
2830
|
+
content += '</div>';
|
|
2831
|
+
content += '</div>';
|
|
2832
|
+
|
|
2833
|
+
return content;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
module.exports = PackagesModule;
|