@signalk/charts-plugin 3.7.0 → 3.8.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 (60) hide show
  1. package/.prettierignore +1 -0
  2. package/CHANGELOG.md +109 -0
  3. package/README.md +72 -0
  4. package/docs/screenshots/1_overview.png +0 -0
  5. package/docs/screenshots/2_region.png +0 -0
  6. package/docs/screenshots/3_jobs.png +0 -0
  7. package/package.json +13 -8
  8. package/plugin/cacheLayout.d.ts +72 -0
  9. package/plugin/cacheLayout.d.ts.map +1 -0
  10. package/plugin/cacheLayout.js +243 -0
  11. package/plugin/cacheLayout.js.map +1 -0
  12. package/plugin/chartDownloader.d.ts +122 -29
  13. package/plugin/chartDownloader.d.ts.map +1 -1
  14. package/plugin/chartDownloader.js +488 -313
  15. package/plugin/chartDownloader.js.map +1 -1
  16. package/plugin/chartDownloaderMBTilesHelpers.d.ts +10 -0
  17. package/plugin/chartDownloaderMBTilesHelpers.d.ts.map +1 -0
  18. package/plugin/chartDownloaderMBTilesHelpers.js +235 -0
  19. package/plugin/chartDownloaderMBTilesHelpers.js.map +1 -0
  20. package/plugin/chartDownloaderTileHelpers.d.ts +30 -0
  21. package/plugin/chartDownloaderTileHelpers.d.ts.map +1 -0
  22. package/plugin/chartDownloaderTileHelpers.js +277 -0
  23. package/plugin/chartDownloaderTileHelpers.js.map +1 -0
  24. package/plugin/charts.d.ts.map +1 -1
  25. package/plugin/charts.js +23 -5
  26. package/plugin/charts.js.map +1 -1
  27. package/plugin/constants.d.ts.map +1 -1
  28. package/plugin/index.d.ts.map +1 -1
  29. package/plugin/index.js +560 -47
  30. package/plugin/index.js.map +1 -1
  31. package/plugin/pluginStatus.js.map +1 -1
  32. package/plugin/projection.js.map +1 -1
  33. package/plugin/tileServer.d.ts +4 -2
  34. package/plugin/tileServer.d.ts.map +1 -1
  35. package/plugin/tileServer.js +37 -8
  36. package/plugin/tileServer.js.map +1 -1
  37. package/plugin/tokenProvider.d.ts +63 -0
  38. package/plugin/tokenProvider.d.ts.map +1 -0
  39. package/plugin/tokenProvider.js +258 -0
  40. package/plugin/tokenProvider.js.map +1 -0
  41. package/plugin/types.d.ts +37 -2
  42. package/plugin/types.d.ts.map +1 -1
  43. package/public/images/jobs.png +0 -0
  44. package/public/images/overview.png +0 -0
  45. package/public/images/region.png +0 -0
  46. package/public/index.html +392 -113
  47. package/public/js/index.js +620 -0
  48. package/public/leaflet/images/downloads.png +0 -0
  49. package/public/leaflet/images/layers-2x.png +0 -0
  50. package/public/leaflet/images/layers.png +0 -0
  51. package/public/leaflet/images/lifebuoy.png +0 -0
  52. package/public/leaflet/images/spritesheet-2x.png +0 -0
  53. package/public/leaflet/images/spritesheet.svg +156 -0
  54. package/public/leaflet/leaflet.css +677 -0
  55. package/public/leaflet/leaflet.draw.css +253 -0
  56. package/public/leaflet/leaflet.draw.js +2957 -0
  57. package/public/leaflet/leaflet.js +7467 -0
  58. package/public/style.css +351 -37
  59. package/docs/screenshots/1_placeholder.png +0 -0
  60. package/public/index.js +0 -349
package/plugin/index.js CHANGED
@@ -42,12 +42,17 @@ const charts_1 = require("./charts");
42
42
  const constants_1 = require("./constants");
43
43
  const pluginStatus_1 = require("./pluginStatus");
44
44
  const chartDownloader_1 = require("./chartDownloader");
45
+ const chartDownloaderMBTilesHelpers_1 = require("./chartDownloaderMBTilesHelpers");
46
+ const tokenProvider_1 = require("./tokenProvider");
47
+ const cacheLayout_1 = require("./cacheLayout");
45
48
  const tileServer_1 = require("./tileServer");
46
49
  const chartTilesPath = '/signalk/chart-tiles';
47
50
  // Debounce window used to collapse FSWatcher bursts during rename / atomic-save
48
51
  // sequences into one reload. Overridable via env var so tests don't have to
49
- // wait the full 5s on every watcher assertion.
50
- const RELOAD_DEBOUNCE_MS = Number(process.env.SK_CHARTS_RELOAD_DEBOUNCE_MS) || 5000;
52
+ // wait the full 5s on every watcher assertion. Read at call time rather than
53
+ // captured at module load so a test can set the env var after importing this
54
+ // module.
55
+ const reloadDebounceMs = () => Number(process.env.SK_CHARTS_RELOAD_DEBOUNCE_MS) || 5000;
51
56
  const createPlugin = (app) => {
52
57
  let chartProviders = {};
53
58
  // Pre-computed per-version views of chartProviders, rebuilt on every reload.
@@ -56,6 +61,13 @@ const createPlugin = (app) => {
56
61
  let sanitizedV1 = {};
57
62
  let sanitizedV2 = {};
58
63
  let pluginStarted = false;
64
+ // pluginStarted vs pluginActive: pluginStarted is "registerRoutes has run"
65
+ // (sticky for the lifetime of the plugin closure — Express routes
66
+ // shouldn't double-register on a stop/start cycle). pluginActive is "is
67
+ // the plugin currently running" — flipped to true at the end of
68
+ // doStartup, false at the start of stop(). Watcher reloads check the
69
+ // latter so a queued reload from before stop() is harmless after stop().
70
+ let pluginActive = false;
59
71
  let providerRegistered = false;
60
72
  let props = {
61
73
  chartPaths: [],
@@ -76,17 +88,29 @@ const createPlugin = (app) => {
76
88
  let reloadTimer;
77
89
  let activeChartPaths = [];
78
90
  let activeOnlineProviders = {};
91
+ // Built from props.tokenProviders during doStartup; merged into
92
+ // chartProviders alongside the online providers. The ChartProvider value
93
+ // here holds an `_tokenProvider` handle so the proxy fetch path can call
94
+ // ensureFreshToken() before reading remoteUrl / headers.
95
+ let activeTokenProviders = {};
96
+ const migrationStatuses = new Map();
97
+ // Cooldown after a migration completes — protects against an admin
98
+ // client polling `migrate` and re-firing on completion. Re-running
99
+ // before the cooldown returns 429.
100
+ const MIGRATION_COOLDOWN_MS = 60_000;
79
101
  // Last scan result, surfaced in the config schema description so the admin
80
- // UI shows per-path counts when the user reopens the plugin config. Issue #8.
102
+ // UI shows per-path counts when the user reopens the plugin config.
81
103
  let lastChartPathCounts = [];
82
104
  // Check Node version for schema
83
105
  const nodeVersion = process.versions.node;
84
106
  const nodeMajorVersion = parseInt(nodeVersion.split('.')[0] ?? '0');
85
- // Builds the `chartPaths` description, appending the latest per-path chart
86
- // counts when available. The admin UI re-fetches the schema every time the
87
- // plugin config page opens, so this text refreshes on reload. Issue #8.
88
- const chartPathsDescription = () => {
89
- const base = `Add one or more paths to find charts. Defaults to "${defaultChartsPath}"`;
107
+ // Builds the chart-path field description, appending the latest per-path
108
+ // chart counts when available. The admin UI re-fetches the schema every time
109
+ // the plugin config page opens, so this text refreshes on reload. Applied to
110
+ // the array's items (not the array) because the admin UI renders an array
111
+ // field's own description twice.
112
+ const chartPathItemDescription = () => {
113
+ const base = `Path to find charts, relative to "${configBasePath}". Defaults to "${defaultChartsPath}"`;
90
114
  if (lastChartPathCounts.length === 0)
91
115
  return base;
92
116
  const parts = lastChartPathCounts.map((p) => `${p.chartPath} (${p.count} ${p.count === 1 ? 'chart' : 'charts'})`);
@@ -111,11 +135,14 @@ const createPlugin = (app) => {
111
135
  chartPaths: {
112
136
  type: 'array',
113
137
  title: 'Chart paths',
114
- description: chartPathsDescription(),
138
+ // Description on the items, not the array: the admin UI renders an
139
+ // array field's own description twice. The per-path scan counts that
140
+ // used to live here are already surfaced in the plugin status banner
141
+ // (see composeStatus), so nothing is lost.
115
142
  items: {
116
143
  type: 'string',
117
144
  title: 'Path',
118
- description: `Path for chart files, relative to "${configBasePath}"`
145
+ description: chartPathItemDescription()
119
146
  }
120
147
  },
121
148
  cachePath: {
@@ -171,8 +198,8 @@ const createPlugin = (app) => {
171
198
  type: 'string',
172
199
  title: 'Format',
173
200
  default: 'png',
174
- enum: ['png', 'jpg', 'pbf'],
175
- description: 'Format of map tiles: raster (png, jpg, etc.) / vector (pbf).'
201
+ enum: ['png', 'jpg', 'pbf', 'mvt', 'webp'],
202
+ description: 'Format of map tiles: raster (png, jpg, webp, etc.) / vector (pbf, mvt).'
176
203
  },
177
204
  url: {
178
205
  type: 'string',
@@ -182,16 +209,18 @@ const createPlugin = (app) => {
182
209
  proxy: {
183
210
  type: 'boolean',
184
211
  title: 'Proxy through signalk server',
185
- description: 'Create a proxy to serve remote tiles and cache fetched tiles from the remote server, to serve them locally on subsequent requests. Use webapp to configure seeding jobs to prefetch tiles to local cache.',
212
+ description: 'Create a proxy to serve remote tiles and cache fetched tiles from the remote server, to serve them locally on subsequent requests. Use webapp to configure seeding jobs to prefetch tiles to local cache. Only meaningful for tilelayer / WMS / WMTS providers; mapstyleJSON and tileJSON providers describe style/source manifests and have no per-tile cache to seed.',
186
213
  default: false
187
214
  },
188
215
  headers: {
189
216
  type: 'array',
190
217
  title: 'Headers',
191
- description: 'List of http headers to be sent to the remote server when requesting map tiles through proxy.',
192
218
  items: {
193
219
  title: 'Header Name: Value',
194
- description: 'Name and Value of the HTTP header separated by colon',
220
+ // Help text on the items, not the array: the admin UI renders
221
+ // an array field's own description twice.
222
+ description: 'HTTP header sent to the remote server when proxying tiles, ' +
223
+ 'as Name: Value (e.g. "User-Agent: MyApp/1.0").',
195
224
  type: 'string'
196
225
  }
197
226
  },
@@ -203,15 +232,118 @@ const createPlugin = (app) => {
203
232
  layers: {
204
233
  type: 'array',
205
234
  title: 'Layers',
206
- description: 'List of map layer ids to display. (Use with WMS / WMTS types.)',
207
235
  items: {
208
236
  title: 'Layer Name',
209
- description: 'Name of layer to display',
237
+ // Help text on the items, not the array: the admin UI renders
238
+ // an array field's own description twice.
239
+ description: 'Map layer id to display (use with WMS / WMTS types).',
210
240
  type: 'string'
211
241
  }
212
242
  }
213
243
  }
214
244
  }
245
+ },
246
+ tokenProviders: {
247
+ type: 'array',
248
+ title: 'Token providers',
249
+ items: {
250
+ type: 'object',
251
+ title: 'Token provider',
252
+ // The array's items carry the help text rather than the array field
253
+ // itself: the admin UI's form renderer prints an array-of-objects
254
+ // `description` twice (once for the array, once for the object
255
+ // items), so keeping it only here renders it once.
256
+ description: 'Declarative configs for chart providers that need a token ' +
257
+ 'fetched from a separate URL. The token is cached for ttlSeconds ' +
258
+ 'and templated into the tile URL/headers as {token.<field>}. ' +
259
+ 'Use {a-b} in URLs for sharded hostnames (random integer in [a,b]).',
260
+ required: ['identifier', 'name', 'tokenEndpoint', 'tile'],
261
+ properties: {
262
+ identifier: {
263
+ type: 'string',
264
+ title: 'Identifier',
265
+ description: 'Stable ID used in tile URLs (URL-safe; e.g. "navionics").'
266
+ },
267
+ name: { type: 'string', title: 'Name' },
268
+ description: { type: 'string', title: 'Description' },
269
+ type: {
270
+ type: 'string',
271
+ title: 'Map source type',
272
+ default: 'tilelayer',
273
+ enum: ['tilelayer', 'WMTS']
274
+ },
275
+ format: {
276
+ type: 'string',
277
+ title: 'Format',
278
+ default: 'png',
279
+ enum: ['png', 'jpg', 'webp', 'pbf', 'mvt']
280
+ },
281
+ scale: { type: 'number', title: 'Scale', default: 250000 },
282
+ minzoom: {
283
+ type: 'number',
284
+ title: 'Min zoom',
285
+ minimum: tileServer_1.MIN_ZOOM,
286
+ maximum: tileServer_1.MAX_ZOOM
287
+ },
288
+ maxzoom: {
289
+ type: 'number',
290
+ title: 'Max zoom',
291
+ minimum: tileServer_1.MIN_ZOOM,
292
+ maximum: tileServer_1.MAX_ZOOM
293
+ },
294
+ bounds: {
295
+ type: 'array',
296
+ title: 'Bounds [minLon, minLat, maxLon, maxLat]',
297
+ items: { type: 'number' },
298
+ minItems: 4,
299
+ maxItems: 4
300
+ },
301
+ tokenEndpoint: {
302
+ type: 'object',
303
+ title: 'Token endpoint',
304
+ required: ['url', 'ttlSeconds'],
305
+ properties: {
306
+ url: { type: 'string', title: 'URL' },
307
+ method: {
308
+ type: 'string',
309
+ title: 'Method',
310
+ default: 'GET',
311
+ enum: ['GET', 'POST']
312
+ },
313
+ headers: {
314
+ type: 'object',
315
+ title: 'Request headers',
316
+ additionalProperties: { type: 'string' }
317
+ },
318
+ body: { type: 'string', title: 'Request body' },
319
+ ttlSeconds: {
320
+ type: 'number',
321
+ title: 'TTL (seconds)',
322
+ description: 'How long a fetched token is reused before re-fetching.',
323
+ minimum: 1
324
+ }
325
+ }
326
+ },
327
+ tile: {
328
+ type: 'object',
329
+ title: 'Tile request template',
330
+ required: ['url'],
331
+ properties: {
332
+ url: {
333
+ type: 'string',
334
+ title: 'URL template',
335
+ description: 'Tile URL with {z}/{x}/{y}, {token.<field>}, and {a-b}.'
336
+ },
337
+ headers: {
338
+ type: 'object',
339
+ title: 'Request headers',
340
+ description: 'Header template (values may reference {token.<field>}).',
341
+ additionalProperties: { type: 'string' }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
215
347
  }
216
348
  }
217
349
  });
@@ -230,10 +362,20 @@ const createPlugin = (app) => {
230
362
  // (config reload, server shutdown, error cascade) is visible in logs
231
363
  // instead of silently emptying chartProviders.
232
364
  console.log(`Signal K Charts: stop() called (${Object.keys(chartProviders).length} provider(s) will be released)`);
365
+ pluginActive = false;
233
366
  stopWatchers();
234
367
  // Cancel any running seeding jobs so a disabled plugin doesn't keep
235
368
  // pulling tiles from remote providers in the background.
236
369
  chartDownloader_1.ChartSeedingManager.cancelAll();
370
+ // Cancel any in-flight legacy migration. Without this, the walk
371
+ // keeps calling putTile against a handle we're about to close —
372
+ // every call errors-and-counts-as-failed for thousands of remaining
373
+ // tiles, and a quick restart can race the orphan loop against a
374
+ // freshly opened connection on the same file.
375
+ for (const status of migrationStatuses.values()) {
376
+ if (status.state === 'running')
377
+ status.controller?.abort();
378
+ }
237
379
  // Close open SQLite connections so the user can move or delete chart
238
380
  // files while the plugin is stopped (Windows blocks deletion on open
239
381
  // handles). Restart re-opens fresh via findCharts.
@@ -272,6 +414,10 @@ const createPlugin = (app) => {
272
414
  if (cachePath !== defaultChartsPath) {
273
415
  await ensureDirectoryExists(cachePath);
274
416
  }
417
+ // Migrate any flat-layout proxy / export files into the .working/
418
+ // and exports/ subdirs the scanner expects. Idempotent and
419
+ // failure-tolerant: a file we can't move is logged and the rest carry on.
420
+ await (0, cacheLayout_1.migrateLegacyCacheLayout)(cachePath, app);
275
421
  activeOnlineProviders = {};
276
422
  for (const data of props.onlineChartProviders ?? []) {
277
423
  const provider = convertOnlineProviderConfig(data);
@@ -282,13 +428,33 @@ const createPlugin = (app) => {
282
428
  }
283
429
  activeOnlineProviders[provider.identifier] = provider;
284
430
  }
285
- app.debug(`Start charts plugin. Chart paths: ${activeChartPaths.join(', ')}, online charts: ${Object.keys(activeOnlineProviders).length}`);
431
+ // Build token providers. A bad config entry shouldn't take the whole
432
+ // plugin out — log and skip the offender so the rest still load.
433
+ activeTokenProviders = {};
434
+ for (let i = 0; i < (props.tokenProviders ?? []).length; i++) {
435
+ const raw = (props.tokenProviders ?? [])[i];
436
+ try {
437
+ const cfg = (0, tokenProvider_1.validateTokenProviderConfig)(raw, i);
438
+ const provider = (0, tokenProvider_1.chartProviderFromTokenConfig)(cfg);
439
+ if (activeTokenProviders[provider.identifier]) {
440
+ app.debug(`Duplicate token provider identifier "${provider.identifier}"; ` +
441
+ `the later entry wins. Rename one to avoid the collision.`);
442
+ }
443
+ activeTokenProviders[provider.identifier] = provider;
444
+ }
445
+ catch (err) {
446
+ console.warn(`Skipping ${err.message}`);
447
+ }
448
+ }
449
+ app.debug(`Start charts plugin. Chart paths: ${activeChartPaths.join(', ')}, online charts: ${Object.keys(activeOnlineProviders).length}, ` +
450
+ `token providers: ${Object.keys(activeTokenProviders).length}`);
286
451
  // Routes and the v2 provider registration are idempotent — Signal K can
287
452
  // call start() again after a config change, but re-registering would
288
453
  // either throw or duplicate the handler.
289
454
  if (!pluginStarted)
290
455
  registerRoutes();
291
456
  pluginStarted = true;
457
+ pluginActive = true;
292
458
  if (serverMajorVersion === 2 && !providerRegistered) {
293
459
  app.debug('** Registering v2 API paths **');
294
460
  registerAsProvider();
@@ -357,17 +523,51 @@ const createPlugin = (app) => {
357
523
  app.setPluginStatus((0, pluginStatus_1.composeStatus)(perPath, Object.keys(activeOnlineProviders).length));
358
524
  return;
359
525
  }
360
- reconcileMbtilesHandles(chartProviders);
361
- // Shallow assign is enough: newCharts and activeOnlineProviders both
362
- // have unique ids per entry; the values themselves are used by reference.
363
- chartProviders = { ...newCharts };
526
+ // Build the next chartProviders dict locally first so request handlers
527
+ // never see a half-populated state. Atomic publish at the end of the
528
+ // function keeps `_mbtilesHandle` invariants intact: a request reading
529
+ // chartProviders[id] for a proxy provider should always find a handle
530
+ // open, never a transient null between the dict-swap and the handle-open.
531
+ const nextChartProviders = {
532
+ ...newCharts
533
+ };
364
534
  for (const [id, provider] of Object.entries(activeOnlineProviders)) {
365
- if (chartProviders[id]) {
535
+ if (nextChartProviders[id]) {
366
536
  app.debug(`Online provider identifier "${id}" collides with a local chart; ` +
367
537
  `the online provider wins.`);
368
538
  }
369
- chartProviders[id] = provider;
539
+ nextChartProviders[id] = provider;
370
540
  }
541
+ // Token providers (declarative token-rotating configs) merge in last
542
+ // and win over a colliding online or local entry: the admin took the
543
+ // explicit step of configuring one, so a stale auto-discovered chart
544
+ // shouldn't shadow it.
545
+ for (const [id, provider] of Object.entries(activeTokenProviders)) {
546
+ if (nextChartProviders[id]) {
547
+ app.debug(`Token provider identifier "${id}" collides with another ` +
548
+ `provider; the token provider wins.`);
549
+ }
550
+ nextChartProviders[id] = provider;
551
+ }
552
+ // Open / create the working mbtiles cache for every proxy provider.
553
+ // Path lives under `.working/<id>/cache.mbtiles` (excluded from the
554
+ // scanner so it isn't read-opened concurrently with our writes).
555
+ // Failure to open doesn't take the provider out — tile requests will
556
+ // just always go remote.
557
+ for (const provider of Object.values(nextChartProviders)) {
558
+ if (provider.proxy === true) {
559
+ try {
560
+ provider._mbtilesHandle = await (0, chartDownloaderMBTilesHelpers_1.openOrCreateMbtiles)((0, cacheLayout_1.workingMbtilesPath)(cachePath, provider.identifier), provider);
561
+ }
562
+ catch (err) {
563
+ app.debug(`Could not open mbtiles cache for ${provider.identifier}: ${err.message}`);
564
+ }
565
+ }
566
+ }
567
+ // Now publish: close handles on the previous set we're replacing, then
568
+ // swap in the fully-populated next set in one assignment.
569
+ reconcileMbtilesHandles(chartProviders);
570
+ chartProviders = nextChartProviders;
371
571
  buildSanitizedCache();
372
572
  app.setPluginStatus((0, pluginStatus_1.composeStatus)(perPath, Object.keys(activeOnlineProviders).length));
373
573
  };
@@ -392,6 +592,13 @@ const createPlugin = (app) => {
392
592
  const MBTILES_CLOSE_DELAY_MS = 1000;
393
593
  const reconcileMbtilesHandles = (oldSet) => {
394
594
  for (const old of Object.values(oldSet)) {
595
+ // Skip providers that were only in the old set if they were proxy providers
596
+ // we want to keep serving tiles from their existing mbtiles handle until
597
+ // the new one is ready, and a missing handle means the new config failed
598
+ // to open the file, so there's no new handle to wait for.
599
+ if (old.proxy === true) {
600
+ continue;
601
+ }
395
602
  if (old?._mbtilesHandle) {
396
603
  const handle = old._mbtilesHandle;
397
604
  setTimeout(() => closeMbtilesHandle(handle), MBTILES_CLOSE_DELAY_MS);
@@ -414,14 +621,37 @@ const createPlugin = (app) => {
414
621
  // Chart folders are watched so new/renamed/deleted files become visible
415
622
  // without a plugin restart. FSWatcher bursts events during rename and
416
623
  // atomic-save sequences; the debounce collapses a burst into one reload.
624
+ // Set while loadChartProviders is running so a watcher event during a
625
+ // reload doesn't kick off a parallel one. Combined with the debounce
626
+ // and the pluginActive check, this means at most one
627
+ // loadChartProviders is in-flight at a time.
628
+ let loadInFlight = false;
417
629
  const scheduleReload = () => {
418
630
  if (reloadTimer)
419
631
  clearTimeout(reloadTimer);
420
632
  reloadTimer = setTimeout(() => {
421
633
  reloadTimer = undefined;
634
+ // Guards against the stop()-during-debounce race: a watcher event
635
+ // queues a reload, then the admin disables the plugin during the
636
+ // debounce window. Without this check the reload fires after stop()
637
+ // has cleared chartProviders, opens fresh mbtiles handles, and the
638
+ // next stop() doesn't know about them.
639
+ if (!pluginActive)
640
+ return;
641
+ if (loadInFlight) {
642
+ // Reschedule rather than drop: the in-flight reload may have
643
+ // captured pre-event state.
644
+ scheduleReload();
645
+ return;
646
+ }
422
647
  app.debug('Reloading charts after filesystem change');
423
- loadChartProviders();
424
- }, RELOAD_DEBOUNCE_MS);
648
+ loadInFlight = true;
649
+ loadChartProviders()
650
+ .catch((e) => console.warn(`[charts-plugin] reload failed: ${e.message}`))
651
+ .finally(() => {
652
+ loadInFlight = false;
653
+ });
654
+ }, reloadDebounceMs());
425
655
  };
426
656
  const startWatchers = () => {
427
657
  stopWatchers();
@@ -496,28 +726,236 @@ const createPlugin = (app) => {
496
726
  }
497
727
  }
498
728
  });
729
+ // Hard cap on the persisted regions.json size. The webapp posts the
730
+ // full FeatureCollection on every save; allowing arbitrary growth
731
+ // would let an admin (or a misbehaving client) fill the data dir.
732
+ const MAX_REGIONS_BYTES = 1024 * 1024; // 1 MB; thousands of detailed polygons
733
+ app.post(`${chartTilesPath}/cache/regions`, async (req, res) => {
734
+ // Validate the body shape: we expect a GeoJSON FeatureCollection.
735
+ // Without this, anything JSON-shaped lands in regions.json and
736
+ // breaks the GET handler / the webapp on next read.
737
+ const body = req.body;
738
+ if (typeof body !== 'object' ||
739
+ body === null ||
740
+ body.type !== 'FeatureCollection' ||
741
+ !Array.isArray(body.features)) {
742
+ return res
743
+ .status(400)
744
+ .send('expected a GeoJSON FeatureCollection with a features array');
745
+ }
746
+ const serialised = JSON.stringify(body, null, 2);
747
+ if (Buffer.byteLength(serialised, 'utf8') > MAX_REGIONS_BYTES) {
748
+ return res
749
+ .status(413)
750
+ .send(`regions payload exceeds ${MAX_REGIONS_BYTES} bytes`);
751
+ }
752
+ // Async write so a slow SD card on a Pi doesn't stall the event
753
+ // loop, and a try/catch so an EACCES / ENOSPC produces a useful
754
+ // 500 instead of an uncaught throw.
755
+ const p = path_1.default.join(app.getDataDirPath(), 'regions.json');
756
+ try {
757
+ await fs_1.default.promises.writeFile(p, serialised, 'utf8');
758
+ }
759
+ catch (err) {
760
+ console.warn(`[charts-plugin] failed to write ${p}: ${err.message}`);
761
+ return res
762
+ .status(500)
763
+ .send(`failed to persist regions: ${err.message}`);
764
+ }
765
+ return res.send({ status: 'ok' });
766
+ });
767
+ app.get(`${chartTilesPath}/cache/regions`, async (_req, res) => {
768
+ const p = path_1.default.join(app.getDataDirPath(), 'regions.json');
769
+ try {
770
+ const raw = await fs_1.default.promises.readFile(p, 'utf8');
771
+ const data = JSON.parse(raw);
772
+ return res.json(data);
773
+ }
774
+ catch (_e) {
775
+ return res.json({ type: 'FeatureCollection', features: [] });
776
+ }
777
+ });
778
+ app.get(`${chartTilesPath}/cache/stats`, (_req, res) => {
779
+ const data = chartDownloader_1.ChartDownloader.getStatistics();
780
+ return res.json(data);
781
+ });
782
+ // Migrate a per-file PNG cache (`<sourceName>/<z>/<x>/<y>.<ext>`) into
783
+ // the provider's working mbtiles. Body fields (all optional):
784
+ // sourceName legacy directory name (default: provider.name)
785
+ // deleteSource remove tile files after successful insert
786
+ // Returns 202 immediately and runs the walk in the background — large
787
+ // caches (>100k tiles) can take minutes and would time out a sync
788
+ // response. State is recorded in `migrationStatuses` so the admin can
789
+ // poll GET /cache/:id/migrate for progress and the final counts. The
790
+ // migration itself is idempotent so re-running is safe.
791
+ app.post(`${chartTilesPath}/cache/:identifier/migrate`, async (req, res) => {
792
+ const { identifier } = req.params;
793
+ const provider = chartProviders[identifier];
794
+ if (!provider)
795
+ return res.status(404).send('Provider not found');
796
+ if (!provider._mbtilesHandle) {
797
+ return res
798
+ .status(400)
799
+ .send(`Provider "${identifier}" has no working mbtiles cache. ` +
800
+ `Configure proxy:true and ensure cachePath is writable.`);
801
+ }
802
+ const existing = migrationStatuses.get(identifier);
803
+ if (existing && existing.state === 'running') {
804
+ return res
805
+ .status(409)
806
+ .send(`Migration for "${identifier}" is already running. ` +
807
+ `Wait for it to complete or check GET /cache/${identifier}/migrate.`);
808
+ }
809
+ // Cooldown after completion: a flapping admin client polling
810
+ // GET .../migrate and re-firing on completion would otherwise
811
+ // walk the directory continuously.
812
+ if (existing &&
813
+ existing.state !== 'running' &&
814
+ existing.completedAt &&
815
+ Date.now() - existing.completedAt < MIGRATION_COOLDOWN_MS) {
816
+ return res
817
+ .status(429)
818
+ .header('Retry-After', String(Math.ceil((MIGRATION_COOLDOWN_MS -
819
+ (Date.now() - existing.completedAt)) /
820
+ 1000)))
821
+ .send(`Migration for "${identifier}" recently ${existing.state}; ` +
822
+ `wait ${Math.ceil(MIGRATION_COOLDOWN_MS / 1000)}s before re-running. ` +
823
+ `Previous result: ${JSON.stringify(existing.counts)}`);
824
+ }
825
+ const body = (req.body ?? {});
826
+ const sourceName = typeof body.sourceName === 'string' && body.sourceName.length > 0
827
+ ? body.sourceName
828
+ : provider.name;
829
+ const legacyDir = path_1.default.resolve(cachePath, sourceName);
830
+ // path.resolve guards against `../` escape, but a naive prefix
831
+ // startsWith check has a false-negative when a sibling directory
832
+ // happens to share the cachePath prefix (e.g. cachePath
833
+ // `/var/lib/sk/charts` + sourceName `../charts-shared` resolves
834
+ // to `/var/lib/sk/charts-shared` which startsWith `/var/lib/sk/charts`).
835
+ // Require the resolved path to equal cachePath OR start with
836
+ // `cachePath + sep`.
837
+ const cacheBase = path_1.default.resolve(cachePath);
838
+ if (legacyDir !== cacheBase &&
839
+ !legacyDir.startsWith(cacheBase + path_1.default.sep)) {
840
+ return res.status(400).send('sourceName must not escape cachePath');
841
+ }
842
+ const handle = provider._mbtilesHandle;
843
+ const deleteSource = body.deleteSource === true;
844
+ // The default cachePath IS the chart-scan path, and a directory-format
845
+ // chart is stored as the same <z>/<x>/<y>.<fmt> tree this migration
846
+ // walks (and, with deleteSource, unlinks). Without this guard a request
847
+ // like { sourceName: "<installed-chart-dir>", deleteSource: true }
848
+ // would drain and delete a user's installed chart. Refuse when the
849
+ // source resolves onto an installed chart.
850
+ const installedChart = resolveInstalledChartCollision(legacyDir);
851
+ if (installedChart) {
852
+ return res
853
+ .status(409)
854
+ .send(`sourceName "${sourceName}" resolves onto installed chart data ` +
855
+ `(${installedChart}). Refusing to migrate it as a legacy cache.`);
856
+ }
857
+ const controller = new AbortController();
858
+ const status = {
859
+ state: 'running',
860
+ startedAt: Date.now(),
861
+ legacyDir,
862
+ deleteSource,
863
+ counts: { migrated: 0, skipped: 0, failed: 0 },
864
+ controller
865
+ };
866
+ migrationStatuses.set(identifier, status);
867
+ console.log(`[charts-plugin] migration for "${identifier}" started ` +
868
+ `legacyDir=${legacyDir} deleteSource=${deleteSource}`);
869
+ // Fire and forget: log on completion / failure and update the
870
+ // shared status entry so GET ../migrate can surface progress.
871
+ // The onProgress callback updates counts every 500 processed
872
+ // entries, giving the admin a way to confirm forward progress
873
+ // without grepping logs.
874
+ const promise = (0, cacheLayout_1.migrateLegacyTileCache)(legacyDir, handle, {
875
+ deleteSource,
876
+ signal: controller.signal,
877
+ onProgress: (counts) => {
878
+ status.counts = { ...counts };
879
+ // Surface progress in the SignalK plugin list so an admin
880
+ // who started a multi-hour migration before bed can see it
881
+ // running on the dashboard without grepping logs.
882
+ const total = counts.migrated + counts.skipped + counts.failed;
883
+ app.setPluginStatus(`Migrating "${identifier}": ${counts.migrated} migrated, ` +
884
+ `${total} processed`);
885
+ }
886
+ })
887
+ .then((counts) => {
888
+ status.counts = counts;
889
+ status.state = controller.signal.aborted ? 'cancelled' : 'completed';
890
+ status.completedAt = Date.now();
891
+ console.log(`[charts-plugin] migration for "${identifier}" ${status.state}: ` +
892
+ `${counts.migrated} migrated, ${counts.skipped} skipped, ` +
893
+ `${counts.failed} failed`);
894
+ app.setPluginStatus(`Migration for "${identifier}" ${status.state}: ` +
895
+ `${counts.migrated} migrated, ${counts.skipped} skipped, ` +
896
+ `${counts.failed} failed`);
897
+ })
898
+ .catch((err) => {
899
+ status.state = 'failed';
900
+ status.completedAt = Date.now();
901
+ status.error = err.message;
902
+ console.warn(`[charts-plugin] migration for "${identifier}" failed: ${err.message}`);
903
+ app.setPluginStatus(`Migration for "${identifier}" failed: ${err.message}`);
904
+ });
905
+ status.promise = promise;
906
+ return res.status(202).json({
907
+ status: 'started',
908
+ provider: identifier,
909
+ legacyDir,
910
+ deleteSource
911
+ });
912
+ });
913
+ // Poll the state of a previously-started legacy migration. Returns 404
914
+ // if no migration has been kicked off for this provider in this plugin
915
+ // session — the state is plugin-scoped, so a stop()/start() cycle
916
+ // clears it.
917
+ app.get(`${chartTilesPath}/cache/:identifier/migrate`, (req, res) => {
918
+ const { identifier } = req.params;
919
+ const status = migrationStatuses.get(identifier);
920
+ if (!status) {
921
+ return res
922
+ .status(404)
923
+ .send(`No migration recorded for "${identifier}" in this session.`);
924
+ }
925
+ return res.status(200).json({ provider: identifier, ...status });
926
+ });
499
927
  app.post(`${chartTilesPath}/cache/:identifier`, async (req, res) => {
500
928
  const { identifier } = req.params;
501
929
  if (!identifier) {
502
930
  return res.sendStatus(404);
503
931
  }
504
- const { regionGUID, tile, bbox, maxZoom } = req.body;
932
+ const { feature, bbox, minZoom, maxZoom, action, options } = req.body;
505
933
  const provider = chartProviders[identifier];
506
934
  if (!provider) {
507
935
  return res.status(404).send('Provider not found');
508
936
  }
937
+ // Reject providers that can't be tile-seeded (non-proxy, or a style /
938
+ // vector type such as mapstyleJSON) with a clear 400 rather than
939
+ // creating a job that fails every tile. The webapp already hides these
940
+ // from the picker; this guards direct API callers.
941
+ const seedableError = (0, tileServer_1.validateSeedableProvider)(provider);
942
+ if (seedableError) {
943
+ return res.status(400).send(seedableError);
944
+ }
509
945
  if (!maxZoom) {
510
946
  return res.status(400).send('maxZoom parameter is required');
511
947
  }
512
- if (!regionGUID && !bbox && !tile) {
513
- return res
514
- .status(400)
515
- .send('Request must include regionGUID, bbox, or tile');
948
+ if (!feature && !bbox) {
949
+ return res.status(400).send('Request must include feature or bbox');
516
950
  }
951
+ const minZoomParsed = parseInt(minZoom ?? '1');
517
952
  const maxZoomParsed = parseInt(maxZoom);
518
- const zoomError = (0, tileServer_1.validateMaxZoom)(maxZoomParsed);
519
- if (zoomError) {
520
- return res.status(400).send(zoomError);
953
+ const maxZoomError = (0, tileServer_1.validateMaxZoom)(maxZoomParsed);
954
+ if (maxZoomError) {
955
+ return res.status(400).send(maxZoomError);
956
+ }
957
+ if (Number.isFinite(minZoomParsed) && (0, tileServer_1.validateMaxZoom)(minZoomParsed)) {
958
+ return res.status(400).send(`Invalid minZoom ${minZoomParsed}`);
521
959
  }
522
960
  if (bbox) {
523
961
  const bboxError = (0, tileServer_1.validateBBox)(bbox);
@@ -525,16 +963,16 @@ const createPlugin = (app) => {
525
963
  return res.status(400).send(bboxError);
526
964
  }
527
965
  }
528
- if (tile) {
529
- const tileError = (0, tileServer_1.validateTileCoords)(tile.z, tile.x, tile.y);
530
- if (tileError) {
531
- return res.status(400).send(tileError);
532
- }
533
- }
534
966
  try {
535
- const job = await chartDownloader_1.ChartSeedingManager.createJob(app.resourcesApi, cachePath, provider, maxZoomParsed, regionGUID, bbox
967
+ const job = await chartDownloader_1.ChartSeedingManager.createJob(cachePath, provider, options ?? { refetch: false, mbtiles: false, vacuum: false }, minZoomParsed, maxZoomParsed, feature, bbox
536
968
  ? [bbox.minLon, bbox.minLat, bbox.maxLon, bbox.maxLat]
537
- : undefined, tile);
969
+ : undefined);
970
+ if (action === 'start') {
971
+ runJobInBackground(job, job.seedCache());
972
+ }
973
+ else if (action === 'delete') {
974
+ runJobInBackground(job, job.deleteCache());
975
+ }
538
976
  // Job is registered and its tile set is known, but nothing has been
539
977
  // downloaded yet — the caller starts it with
540
978
  // POST /cache/jobs/:id { action: 'start' }.
@@ -570,15 +1008,30 @@ const createPlugin = (app) => {
570
1008
  return res.status(400).send('action parameter is required');
571
1009
  }
572
1010
  if (action === 'start') {
573
- job.seedCache();
1011
+ // Reject restart of jobs that already ran. seedCache itself is a
1012
+ // no-op while Running, but a Completed (or Cancelled) job would
1013
+ // happily reset its counters and run again. The admin should
1014
+ // remove the completed job and create a fresh one to refresh
1015
+ // tiles, so the intent is explicit and doesn't get lost in a
1016
+ // counter reset.
1017
+ if (job.info().status !== 'Idle') {
1018
+ return res
1019
+ .status(409)
1020
+ .send(`Job ${parsedId} cannot be started (status: ${job.info().status}). ` +
1021
+ `Remove and recreate the job to seed again.`);
1022
+ }
1023
+ runJobInBackground(job, job.seedCache());
574
1024
  }
575
1025
  else if (action === 'stop') {
576
1026
  job.cancelJob();
577
1027
  }
578
1028
  else if (action === 'delete') {
579
- job.deleteCache();
1029
+ runJobInBackground(job, job.deleteCache());
580
1030
  }
581
1031
  else if (action === 'remove') {
1032
+ // Cancel first so an in-flight seed doesn't mutate the entry
1033
+ // after we drop it from the registry.
1034
+ job.cancelJob();
582
1035
  delete chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
583
1036
  }
584
1037
  else {
@@ -645,6 +1098,37 @@ const resolveUniqueChartPaths = (chartPaths, configBasePath) => {
645
1098
  const paths = chartPaths.map((chartPath) => path_1.default.resolve(configBasePath, chartPath));
646
1099
  return [...new Set(paths)];
647
1100
  };
1101
+ // Returns the candidate path if it is an installed directory-format chart, or
1102
+ // null if it is safe to migrate as a legacy proxy cache. The discriminator is
1103
+ // the same marker the scanner uses (tilemapresource.xml / metadata.json): a
1104
+ // legacy PNG cache is a bare <z>/<x>/<y> tree with no marker, whereas an
1105
+ // installed TMS/XYZ chart has one. This stops the legacy-cache migration —
1106
+ // which walks and (with deleteSource) unlinks a <z>/<x>/<y> tree — from
1107
+ // cannibalising a user's installed chart, a real risk because the default
1108
+ // cachePath and the default chart path are the same directory and a directory
1109
+ // chart has the identical on-disk tile layout.
1110
+ const INSTALLED_CHART_MARKERS = ['tilemapresource.xml', 'metadata.json'];
1111
+ // seedCache() and deleteCache() are fire-and-forget: the HTTP handler returns
1112
+ // 202 immediately and the work continues in the background. Both methods record
1113
+ // a failure on the job and reset its state internally, but the returned promise
1114
+ // still rejects on error — and an un-awaited rejected promise is an unhandled
1115
+ // rejection, which on Node 22+ terminates the whole Signal K server. This
1116
+ // attaches the terminal .catch() so a failed seed/delete degrades the job, not
1117
+ // the process.
1118
+ const runJobInBackground = (job, work) => {
1119
+ work.catch((err) => {
1120
+ console.error(`[charts-plugin] background job ${job.info().id} failed: ${err.message}`);
1121
+ });
1122
+ };
1123
+ const resolveInstalledChartCollision = (candidate) => {
1124
+ const resolved = path_1.default.resolve(candidate);
1125
+ for (const marker of INSTALLED_CHART_MARKERS) {
1126
+ if (fs_1.default.existsSync(path_1.default.join(resolved, marker))) {
1127
+ return resolved;
1128
+ }
1129
+ }
1130
+ return null;
1131
+ };
648
1132
  const convertOnlineProviderConfig = (provider) => {
649
1133
  const id = _.kebabCase(_.deburr(provider.name));
650
1134
  const parseHeaders = (arr) => {
@@ -693,14 +1177,43 @@ const convertOnlineProviderConfig = (provider) => {
693
1177
  // Builds the outward-facing view of a provider for either the v1 or v2 API.
694
1178
  // Copies non-private top-level fields, then overlays the version-specific
695
1179
  // block (v1 has tilemapUrl/chartLayers, v2 has url/layers), rewriting the
696
- // tile-path placeholder. Intentionally a single shallow walk — the previous
697
- // implementation did three deep clones per call and ran per metadata request.
1180
+ // tile-path placeholder. Intentionally a single shallow walk to keep
1181
+ // per-metadata-request cost bounded.
1182
+ //
1183
+ // `name` and `description` go through stripHtml first: the webapp's
1184
+ // Leaflet L.Control.Layers writes them into innerHTML, so a malicious
1185
+ // .mbtiles shipping a metadata.name like
1186
+ // `<img src=x onerror="..."> ` would execute in the admin's session.
1187
+ // Server-side sanitisation lets the (vendored) UI keep its rendering
1188
+ // path without per-call escaping.
1189
+ const stripHtml = (s) => s
1190
+ // Drop tags entirely. We don't try to preserve any HTML — this is
1191
+ // chart metadata from a SQLite blob, not rich text.
1192
+ .replace(/<[^>]*>/g, '')
1193
+ // Belt-and-braces: collapse any standalone & < > so an unbalanced
1194
+ // input doesn't produce something the browser still treats as markup.
1195
+ .replace(/[<>]/g, '');
698
1196
  const sanitizeProvider = (provider, version = 1) => {
699
1197
  const out = {};
700
1198
  for (const [key, value] of Object.entries(provider)) {
701
1199
  if (key.startsWith('_') || key === 'v1' || key === 'v2')
702
1200
  continue;
703
- out[key] = value;
1201
+ // remoteUrl / headers are the upstream proxy target and its request
1202
+ // headers — for token providers these are getters that resolve a live
1203
+ // bearer token. Clients fetch tiles through the proxy path (~tilePath~),
1204
+ // never the upstream directly, so these must not appear in the public
1205
+ // resources view; emitting them would leak the rotating credential.
1206
+ if (key === 'remoteUrl' || key === 'headers')
1207
+ continue;
1208
+ // Defang any string field that the webapp might later render via
1209
+ // innerHTML (Leaflet L.Control.Layers, custom tooltips, etc).
1210
+ if ((key === 'name' || key === 'description') &&
1211
+ typeof value === 'string') {
1212
+ out[key] = stripHtml(value);
1213
+ }
1214
+ else {
1215
+ out[key] = value;
1216
+ }
704
1217
  }
705
1218
  const v = version === 1 ? provider.v1 : provider.v2;
706
1219
  if (v) {