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.
Files changed (277) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/FHIRsmith.png +0 -0
  3. package/README.md +277 -0
  4. package/config-template.json +144 -0
  5. package/library/folder-setup.js +58 -0
  6. package/library/html-server.js +166 -0
  7. package/library/html.js +835 -0
  8. package/library/i18nsupport.js +259 -0
  9. package/library/languages.js +779 -0
  10. package/library/logger-telnet.js +205 -0
  11. package/library/logger.js +279 -0
  12. package/library/package-manager.js +876 -0
  13. package/library/utilities.js +196 -0
  14. package/library/version-utilities.js +1056 -0
  15. package/npmprojector/config-example.json +13 -0
  16. package/npmprojector/indexer.js +394 -0
  17. package/npmprojector/npmprojector.js +395 -0
  18. package/npmprojector/readme.md +174 -0
  19. package/npmprojector/watcher.js +335 -0
  20. package/package.json +119 -0
  21. package/packages/package-crawler.js +846 -0
  22. package/packages/packages-template.html +126 -0
  23. package/packages/packages.js +2838 -0
  24. package/passwords.ini +2 -0
  25. package/publisher/publisher-template.html +208 -0
  26. package/publisher/publisher.js +2167 -0
  27. package/publisher/task-draft.js +458 -0
  28. package/registry/api.js +735 -0
  29. package/registry/crawler.js +637 -0
  30. package/registry/model.js +513 -0
  31. package/registry/readme.md +243 -0
  32. package/registry/registry-data.json +121015 -0
  33. package/registry/registry-template.html +126 -0
  34. package/registry/registry.js +1395 -0
  35. package/registry/test-runner.js +237 -0
  36. package/root-template.html +124 -0
  37. package/server.js +524 -0
  38. package/shl/private-key.pem +5 -0
  39. package/shl/public-key.pem +18 -0
  40. package/shl/shl.js +1125 -0
  41. package/shl/vhl.js +69 -0
  42. package/static/FHIRsmith128.png +0 -0
  43. package/static/FHIRsmith16.png +0 -0
  44. package/static/FHIRsmith32.png +0 -0
  45. package/static/FHIRsmith64.png +0 -0
  46. package/static/assets/css/bootstrap-fhir.css +5302 -0
  47. package/static/assets/css/bootstrap-glyphicons.css +2 -0
  48. package/static/assets/css/bootstrap.css +4097 -0
  49. package/static/assets/css/jquery-ui.css +523 -0
  50. package/static/assets/css/jquery-ui.structure.css +863 -0
  51. package/static/assets/css/jquery-ui.structure.min.css +5 -0
  52. package/static/assets/css/jquery-ui.theme.css +439 -0
  53. package/static/assets/css/jquery-ui.theme.min.css +5 -0
  54. package/static/assets/css/jquery.ui.all.css +7 -0
  55. package/static/assets/css/modules.css +18 -0
  56. package/static/assets/css/project.css +367 -0
  57. package/static/assets/css/pygments-manni.css +66 -0
  58. package/static/assets/css/tags.css +74 -0
  59. package/static/assets/css/xml.css +2 -0
  60. package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
  61. package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
  62. package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
  63. package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
  64. package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
  65. package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
  66. package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
  67. package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
  68. package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
  69. package/static/assets/ico/favicon.ico +0 -0
  70. package/static/assets/ico/favicon.png +0 -0
  71. package/static/assets/images/fhir-logo-www.png +0 -0
  72. package/static/assets/images/fhir-logo.png +0 -0
  73. package/static/assets/images/hl7-logo.png +0 -0
  74. package/static/assets/images/logo_ansinew.jpg +0 -0
  75. package/static/assets/images/search.png +0 -0
  76. package/static/assets/images/stripe.png +0 -0
  77. package/static/assets/images/target.png +0 -0
  78. package/static/assets/images/tx-registry-root.gif +0 -0
  79. package/static/assets/images/tx-registry.png +0 -0
  80. package/static/assets/images/tx-server.png +0 -0
  81. package/static/assets/images/tx-version.png +0 -0
  82. package/static/assets/js/bootstrap.min.js +6 -0
  83. package/static/assets/js/fhir-gw.js +259 -0
  84. package/static/assets/js/fhir.js +2 -0
  85. package/static/assets/js/html5shiv.js +8 -0
  86. package/static/assets/js/jcookie.js +96 -0
  87. package/static/assets/js/jquery-ui.min.js +6 -0
  88. package/static/assets/js/jquery.js +10716 -0
  89. package/static/assets/js/jquery.min.js +2 -0
  90. package/static/assets/js/jquery.ui.core.js +314 -0
  91. package/static/assets/js/jquery.ui.draggable.js +825 -0
  92. package/static/assets/js/jquery.ui.mouse.js +162 -0
  93. package/static/assets/js/jquery.ui.resizable.js +842 -0
  94. package/static/assets/js/jquery.ui.widget.js +268 -0
  95. package/static/assets/js/json2.js +487 -0
  96. package/static/assets/js/jtip.js +97 -0
  97. package/static/assets/js/respond.min.js +6 -0
  98. package/static/assets/js/statuspage.js +70 -0
  99. package/static/assets/js/xml.js +2 -0
  100. package/static/dist/js/bootstrap.js +1964 -0
  101. package/static/favicon.png +0 -0
  102. package/static/fhir.css +626 -0
  103. package/static/icon-fhir-16.png +0 -0
  104. package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
  105. package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
  106. package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
  107. package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
  108. package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
  109. package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  110. package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
  111. package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
  112. package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
  113. package/static/images/ui-icons_222222_256x240.png +0 -0
  114. package/static/images/ui-icons_228ef1_256x240.png +0 -0
  115. package/static/images/ui-icons_ef8c08_256x240.png +0 -0
  116. package/static/images/ui-icons_ffd27a_256x240.png +0 -0
  117. package/static/images/ui-icons_ffffff_256x240.png +0 -0
  118. package/static/js/jquery.effects.blind.js +49 -0
  119. package/static/js/jquery.effects.bounce.js +78 -0
  120. package/static/js/jquery.effects.clip.js +54 -0
  121. package/static/js/jquery.effects.core.js +763 -0
  122. package/static/js/jquery.effects.drop.js +50 -0
  123. package/static/js/jquery.effects.explode.js +79 -0
  124. package/static/js/jquery.effects.fade.js +32 -0
  125. package/static/js/jquery.effects.fold.js +56 -0
  126. package/static/js/jquery.effects.highlight.js +50 -0
  127. package/static/js/jquery.effects.pulsate.js +51 -0
  128. package/static/js/jquery.effects.scale.js +178 -0
  129. package/static/js/jquery.effects.shake.js +57 -0
  130. package/static/js/jquery.effects.slide.js +50 -0
  131. package/static/js/jquery.effects.transfer.js +45 -0
  132. package/static/js/jquery.ui.accordion.js +611 -0
  133. package/static/js/jquery.ui.autocomplete.js +612 -0
  134. package/static/js/jquery.ui.button.js +416 -0
  135. package/static/js/jquery.ui.datepicker.js +1823 -0
  136. package/static/js/jquery.ui.dialog.js +878 -0
  137. package/static/js/jquery.ui.droppable.js +296 -0
  138. package/static/js/jquery.ui.position.js +252 -0
  139. package/static/js/jquery.ui.progressbar.js +109 -0
  140. package/static/js/jquery.ui.selectable.js +266 -0
  141. package/static/js/jquery.ui.slider.js +666 -0
  142. package/static/js/jquery.ui.sortable.js +1077 -0
  143. package/static/js/jquery.ui.tabs.js +758 -0
  144. package/stats.js +80 -0
  145. package/test-cache/vsac/vsac-valuesets.db +0 -0
  146. package/token/nginx_passport_setup.md +383 -0
  147. package/token/security_guide.md +294 -0
  148. package/token/token-template.html +330 -0
  149. package/token/token.js +1300 -0
  150. package/translations/Messages.properties +1510 -0
  151. package/translations/Messages_ar.properties +1399 -0
  152. package/translations/Messages_de.properties +836 -0
  153. package/translations/Messages_es.properties +737 -0
  154. package/translations/Messages_fr.properties +1 -0
  155. package/translations/Messages_ja.properties +893 -0
  156. package/translations/Messages_nl.properties +1357 -0
  157. package/translations/Messages_pt.properties +1302 -0
  158. package/translations/Messages_ru.properties +1 -0
  159. package/translations/Messages_uz.properties +1 -0
  160. package/translations/Messages_zh.properties +1 -0
  161. package/translations/rendering-phrases.properties +1128 -0
  162. package/translations/rendering-phrases_ar.properties +1091 -0
  163. package/translations/rendering-phrases_de.properties +6 -0
  164. package/translations/rendering-phrases_es.properties +6 -0
  165. package/translations/rendering-phrases_fr.properties +624 -0
  166. package/translations/rendering-phrases_ja.properties +21 -0
  167. package/translations/rendering-phrases_nl.properties +970 -0
  168. package/translations/rendering-phrases_pt.properties +1020 -0
  169. package/translations/rendering-phrases_ru.properties +1094 -0
  170. package/translations/rendering-phrases_uz.properties +1 -0
  171. package/translations/rendering-phrases_zh.properties +1 -0
  172. package/tx/README.md +418 -0
  173. package/tx/cm/cm-api.js +110 -0
  174. package/tx/cm/cm-database.js +735 -0
  175. package/tx/cm/cm-package.js +325 -0
  176. package/tx/cs/cs-api.js +789 -0
  177. package/tx/cs/cs-areacode.js +615 -0
  178. package/tx/cs/cs-country.js +1110 -0
  179. package/tx/cs/cs-cpt.js +785 -0
  180. package/tx/cs/cs-cs.js +1579 -0
  181. package/tx/cs/cs-currency.js +539 -0
  182. package/tx/cs/cs-db.js +1321 -0
  183. package/tx/cs/cs-hgvs.js +329 -0
  184. package/tx/cs/cs-lang.js +465 -0
  185. package/tx/cs/cs-loinc.js +1485 -0
  186. package/tx/cs/cs-mimetypes.js +238 -0
  187. package/tx/cs/cs-ndc.js +704 -0
  188. package/tx/cs/cs-omop.js +1025 -0
  189. package/tx/cs/cs-provider-api.js +43 -0
  190. package/tx/cs/cs-provider-list.js +37 -0
  191. package/tx/cs/cs-rxnorm.js +808 -0
  192. package/tx/cs/cs-snomed.js +1102 -0
  193. package/tx/cs/cs-ucum.js +514 -0
  194. package/tx/cs/cs-unii.js +271 -0
  195. package/tx/cs/cs-uri.js +218 -0
  196. package/tx/cs/cs-usstates.js +305 -0
  197. package/tx/dev.fhir.org.yml +14 -0
  198. package/tx/fixtures/test-cases-setup.json +18 -0
  199. package/tx/fixtures/test-cases.yml +16 -0
  200. package/tx/html/codesystem-operations.liquid +25 -0
  201. package/tx/html/home-metrics.liquid +247 -0
  202. package/tx/html/operations-form.liquid +148 -0
  203. package/tx/html/search-form.liquid +62 -0
  204. package/tx/html/tx-template.html +133 -0
  205. package/tx/html/valueset-operations.liquid +54 -0
  206. package/tx/importers/atc-to-fhir.js +316 -0
  207. package/tx/importers/import-loinc.module.js +1536 -0
  208. package/tx/importers/import-ndc.module.js +1088 -0
  209. package/tx/importers/import-rxnorm.module.js +898 -0
  210. package/tx/importers/import-sct.module.js +2457 -0
  211. package/tx/importers/import-unii.module.js +601 -0
  212. package/tx/importers/readme.md +453 -0
  213. package/tx/importers/subset-loinc.module.js +1081 -0
  214. package/tx/importers/subset-rxnorm.module.js +938 -0
  215. package/tx/importers/tx-import-base.js +351 -0
  216. package/tx/importers/tx-import-settings.js +310 -0
  217. package/tx/importers/tx-import.js +357 -0
  218. package/tx/library/canonical-resource.js +88 -0
  219. package/tx/library/capabilitystatement.js +292 -0
  220. package/tx/library/codesystem.js +774 -0
  221. package/tx/library/conceptmap.js +568 -0
  222. package/tx/library/designations.js +932 -0
  223. package/tx/library/errors.js +77 -0
  224. package/tx/library/extensions.js +117 -0
  225. package/tx/library/namingsystem.js +322 -0
  226. package/tx/library/operation-outcome.js +127 -0
  227. package/tx/library/parameters.js +105 -0
  228. package/tx/library/renderer.js +1559 -0
  229. package/tx/library/terminologycapabilities.js +418 -0
  230. package/tx/library/ucum-parsers.js +1029 -0
  231. package/tx/library/ucum-service.js +370 -0
  232. package/tx/library/ucum-types.js +1099 -0
  233. package/tx/library/valueset.js +543 -0
  234. package/tx/library.js +676 -0
  235. package/tx/ocl/cm-ocl.js +106 -0
  236. package/tx/ocl/cs-ocl.js +39 -0
  237. package/tx/ocl/vs-ocl.js +105 -0
  238. package/tx/operation-context.js +568 -0
  239. package/tx/params.js +613 -0
  240. package/tx/provider.js +403 -0
  241. package/tx/sct/ecl.js +1560 -0
  242. package/tx/sct/expressions.js +2077 -0
  243. package/tx/sct/structures.js +1396 -0
  244. package/tx/tx-html.js +1063 -0
  245. package/tx/tx.fhir.org.yml +39 -0
  246. package/tx/tx.js +927 -0
  247. package/tx/vs/vs-api.js +112 -0
  248. package/tx/vs/vs-database.js +786 -0
  249. package/tx/vs/vs-package.js +358 -0
  250. package/tx/vs/vs-vsac.js +366 -0
  251. package/tx/workers/batch-validate.js +129 -0
  252. package/tx/workers/batch.js +361 -0
  253. package/tx/workers/closure.js +32 -0
  254. package/tx/workers/expand.js +1845 -0
  255. package/tx/workers/lookup.js +407 -0
  256. package/tx/workers/metadata.js +467 -0
  257. package/tx/workers/operations.js +34 -0
  258. package/tx/workers/read.js +164 -0
  259. package/tx/workers/search.js +384 -0
  260. package/tx/workers/subsumes.js +334 -0
  261. package/tx/workers/translate.js +492 -0
  262. package/tx/workers/validate.js +2504 -0
  263. package/tx/workers/worker.js +904 -0
  264. package/tx/xml/capabilitystatement-xml.js +63 -0
  265. package/tx/xml/codesystem-xml.js +62 -0
  266. package/tx/xml/conceptmap-xml.js +65 -0
  267. package/tx/xml/namingsystem-xml.js +65 -0
  268. package/tx/xml/operationoutcome-xml.js +127 -0
  269. package/tx/xml/parameters-xml.js +312 -0
  270. package/tx/xml/terminologycapabilities-xml.js +64 -0
  271. package/tx/xml/valueset-xml.js +64 -0
  272. package/tx/xml/xml-base.js +603 -0
  273. package/vcl/vcl-parser.js +1098 -0
  274. package/vcl/vcl.js +253 -0
  275. package/windows-install.js +19 -0
  276. package/xig/xig-template.html +124 -0
  277. 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
+ '<': '&lt;',
135
+ '>': '&gt;',
136
+ '"': '&quot;',
137
+ "'": '&#x27;',
138
+ '/': '&#x2F;',
139
+ '`': '&#x60;',
140
+ '=': '&#x3D;'
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> &nbsp;&nbsp;';
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 += '&nbsp;<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;