@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.
- package/.prettierignore +1 -0
- package/CHANGELOG.md +109 -0
- package/README.md +72 -0
- package/docs/screenshots/1_overview.png +0 -0
- package/docs/screenshots/2_region.png +0 -0
- package/docs/screenshots/3_jobs.png +0 -0
- package/package.json +13 -8
- package/plugin/cacheLayout.d.ts +72 -0
- package/plugin/cacheLayout.d.ts.map +1 -0
- package/plugin/cacheLayout.js +243 -0
- package/plugin/cacheLayout.js.map +1 -0
- package/plugin/chartDownloader.d.ts +122 -29
- package/plugin/chartDownloader.d.ts.map +1 -1
- package/plugin/chartDownloader.js +488 -313
- package/plugin/chartDownloader.js.map +1 -1
- package/plugin/chartDownloaderMBTilesHelpers.d.ts +10 -0
- package/plugin/chartDownloaderMBTilesHelpers.d.ts.map +1 -0
- package/plugin/chartDownloaderMBTilesHelpers.js +235 -0
- package/plugin/chartDownloaderMBTilesHelpers.js.map +1 -0
- package/plugin/chartDownloaderTileHelpers.d.ts +30 -0
- package/plugin/chartDownloaderTileHelpers.d.ts.map +1 -0
- package/plugin/chartDownloaderTileHelpers.js +277 -0
- package/plugin/chartDownloaderTileHelpers.js.map +1 -0
- package/plugin/charts.d.ts.map +1 -1
- package/plugin/charts.js +23 -5
- package/plugin/charts.js.map +1 -1
- package/plugin/constants.d.ts.map +1 -1
- package/plugin/index.d.ts.map +1 -1
- package/plugin/index.js +560 -47
- package/plugin/index.js.map +1 -1
- package/plugin/pluginStatus.js.map +1 -1
- package/plugin/projection.js.map +1 -1
- package/plugin/tileServer.d.ts +4 -2
- package/plugin/tileServer.d.ts.map +1 -1
- package/plugin/tileServer.js +37 -8
- package/plugin/tileServer.js.map +1 -1
- package/plugin/tokenProvider.d.ts +63 -0
- package/plugin/tokenProvider.d.ts.map +1 -0
- package/plugin/tokenProvider.js +258 -0
- package/plugin/tokenProvider.js.map +1 -0
- package/plugin/types.d.ts +37 -2
- package/plugin/types.d.ts.map +1 -1
- package/public/images/jobs.png +0 -0
- package/public/images/overview.png +0 -0
- package/public/images/region.png +0 -0
- package/public/index.html +392 -113
- package/public/js/index.js +620 -0
- package/public/leaflet/images/downloads.png +0 -0
- package/public/leaflet/images/layers-2x.png +0 -0
- package/public/leaflet/images/layers.png +0 -0
- package/public/leaflet/images/lifebuoy.png +0 -0
- package/public/leaflet/images/spritesheet-2x.png +0 -0
- package/public/leaflet/images/spritesheet.svg +156 -0
- package/public/leaflet/leaflet.css +677 -0
- package/public/leaflet/leaflet.draw.css +253 -0
- package/public/leaflet/leaflet.draw.js +2957 -0
- package/public/leaflet/leaflet.js +7467 -0
- package/public/style.css +351 -37
- package/docs/screenshots/1_placeholder.png +0 -0
- 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
|
-
|
|
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.
|
|
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
|
|
86
|
-
// counts when available. The admin UI re-fetches the schema every time
|
|
87
|
-
// plugin config page opens, so this text refreshes on reload.
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
chartProviders
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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 {
|
|
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 (!
|
|
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
|
|
519
|
-
if (
|
|
520
|
-
return res.status(400).send(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
697
|
-
//
|
|
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
|
-
|
|
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) {
|