signalk-charts-provider-simple 2.4.1 → 2.5.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/README.md CHANGED
@@ -66,6 +66,12 @@ The interface provides five tabs:
66
66
  1. **Manage Charts**:
67
67
  - View all charts with metadata (name, bounds, zoom levels, size)
68
68
  - Enable/disable, organize into folders, upload, delete, rename
69
+ - **Upload ZIP** imports a whole archive at once: every `.mbtiles` inside it
70
+ is extracted into the selected folder (nested folders are flattened, and
71
+ non-chart files in the archive are ignored). Multi-GB archives are
72
+ supported — large ones upload in chunks automatically, so a slow WiFi
73
+ link can't run into the server's request timeout. Extraction needs
74
+ roughly twice the archive size free on disk while it runs.
69
75
  - S-57 charts shown with ENC badge
70
76
  - Converting charts shown with progress indicator
71
77
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAsHxD,OAAO,KAAK,EACV,aAAa,EAEb,iBAAiB,EAIjB,QAAQ,EAET,MAAM,YAAY,CAAC;AAwBpB,QAAA,MAAM,iBAAiB,GAAI,KAAK,iBAAiB,KAAG,MA49GnD,CAAC;AA4GF,eAAO,MAAM,oBAAoB,GAC/B,KAAK,QAAQ,EACb,UAAU,aAAa,EACvB,GAAG,MAAM,EACT,GAAG,MAAM,EACT,GAAG,MAAM,KACR,IAiDF,CAAC;AAEF,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAuHxD,OAAO,KAAK,EACV,aAAa,EAEb,iBAAiB,EAIjB,QAAQ,EAET,MAAM,YAAY,CAAC;AAwBpB,QAAA,MAAM,iBAAiB,GAAI,KAAK,iBAAiB,KAAG,MAg7HnD,CAAC;AA4GF,eAAO,MAAM,oBAAoB,GAC/B,KAAK,QAAQ,EACb,UAAU,aAAa,EACvB,GAAG,MAAM,EACT,GAAG,MAAM,EACT,GAAG,MAAM,KACR,IAiDF,CAAC;AAEF,eAAe,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { repairMbtilesMetadata, setMbtilesDisplayName } from './utils/mbtiles-me
19
19
  import { open as openMbtilesReader } from './utils/mbtiles-reader.js';
20
20
  import { writeChartPathMarker } from './utils/path-marker.js';
21
21
  import { getBlankTileReplacement, getOverzoomedTile } from './utils/tile-overzoom.js';
22
+ import { extractMbtilesFromZip, ZipLimitError } from './utils/zip-extract.js';
22
23
  import { arePairWithinBase, classifyChartDirAccess, hasHostChartsMountEnv, isHostMountPath, isWithinBase, resolveDefaultChartsPath, validateChartName } from './utils/path-safety.js';
23
24
  import { parsePluginConfig } from './utils/plugin-config-schema.js';
24
25
  import { cleanupQuarantineDir, makeQuarantineDir, makeUniqueQuarantineDir, promoteQuarantine, sweepStaleQuarantineDirs } from './utils/quarantine.js';
@@ -534,6 +535,61 @@ const pluginConstructor = (app) => {
534
535
  }
535
536
  }
536
537
  };
538
+ /**
539
+ * Extract a staged ZIP and promote the charts it contains, answering the
540
+ * request on every outcome. Shared by the single-request and chunked ZIP
541
+ * routes so their error mapping can't drift apart.
542
+ *
543
+ * Returns true when charts were promoted; false means the response has
544
+ * already been sent and the caller should stop.
545
+ */
546
+ const extractAndPromoteZip = async (stagedZipPath, quarantineDir, archiveName, targetFolder, promoteTarget, basePath, res) => {
547
+ const extractDir = path.join(quarantineDir, 'extracted');
548
+ let extracted;
549
+ try {
550
+ extracted = await extractMbtilesFromZip(stagedZipPath, extractDir);
551
+ }
552
+ catch (err) {
553
+ if (err instanceof ZipLimitError) {
554
+ res.status(413).json({ success: false, error: err.message });
555
+ return false;
556
+ }
557
+ // Running out of disk is an expected failure at these sizes — a
558
+ // multi-GB archive is staged *and* extracted, so it needs roughly
559
+ // twice its own size free. Reporting that as "is it a valid ZIP
560
+ // file?" sends the user off checking a file that was never at fault.
561
+ if (err.code === 'ENOSPC') {
562
+ res.status(507).json({
563
+ success: false,
564
+ error: 'Not enough free disk space to extract this archive. Extraction needs about twice the archive size free while it runs.'
565
+ });
566
+ return false;
567
+ }
568
+ app.error(`Error extracting ZIP ${archiveName}: ` + (err instanceof Error ? err.message : String(err)));
569
+ res.status(400).json({
570
+ success: false,
571
+ error: 'Could not read the ZIP archive. Is it a valid ZIP file?'
572
+ });
573
+ return false;
574
+ }
575
+ if (extracted.files.length === 0) {
576
+ res.status(400).json({
577
+ success: false,
578
+ error: 'No .mbtiles files found in the ZIP archive'
579
+ });
580
+ return false;
581
+ }
582
+ await promoteQuarantine(extractDir, extracted.files, promoteTarget);
583
+ await finalizeUploadedFiles(extracted.files, targetFolder, basePath);
584
+ app.debug(`Extracted ${extracted.files.length} chart(s) from ${archiveName} into ${promoteTarget}`);
585
+ res.status(200).json({
586
+ success: true,
587
+ message: `${extracted.files.length} chart${extracted.files.length !== 1 ? 's' : ''} extracted from ${archiveName}`,
588
+ files: extracted.files,
589
+ skipped: extracted.skipped
590
+ });
591
+ return true;
592
+ };
537
593
  // Tippecanoe accepts zooms 0–22 in practice (anything beyond produces
538
594
  // 4×4 pixel tiles per source feature; nothing useful comes out and the
539
595
  // job runs much longer). Bound the inputs to that envelope so a typo
@@ -593,6 +649,31 @@ const pluginConstructor = (app) => {
593
649
  const UploadFields = Type.Object({
594
650
  targetFolder: Type.Optional(Type.String())
595
651
  });
652
+ const UploadZipFields = Type.Object({
653
+ targetFolder: Type.Optional(Type.String())
654
+ });
655
+ // Ceiling on a single uploaded chart ZIP. Deliberately high: a single
656
+ // regional MBTiles set routinely runs to 6 GB or more, and the ZIP is
657
+ // staged whole before extraction, so the limit has to clear real-world
658
+ // chart bundles with room to spare. Still bounded, so a runaway upload
659
+ // can't silently fill the data volume. The extractor applies its own
660
+ // uncompressed-size budget on top of this.
661
+ //
662
+ // Sizes above 4 GB require the archive to use ZIP64; `unzipper` reads
663
+ // ZIP64 central directories, and the 64-bit sizes stay well inside
664
+ // JavaScript's safe-integer range, so the arithmetic here is exact.
665
+ const MAX_ZIP_UPLOAD_BYTES = 32 * 1024 * 1024 * 1024;
666
+ // `x-upload-id` names a quarantine subdirectory, so it's restricted to a
667
+ // conservative charset here rather than relying on downstream sanitizing:
668
+ // no separators, no dots, nothing that could walk out of the quarantine
669
+ // root even before `makeQuarantineDir` sanitizes it.
670
+ const UploadZipChunkHeaders = Type.Object({
671
+ 'x-upload-id': Type.String({ minLength: 1, maxLength: 64, pattern: '^[A-Za-z0-9_-]+$' }),
672
+ 'x-upload-filename': Type.String({ minLength: 1, pattern: '\\.[zZ][iI][pP]$' }),
673
+ 'x-chunk-index': Type.Integer({ minimum: 0 }),
674
+ 'x-total-chunks': Type.Integer({ minimum: 1 }),
675
+ 'x-target-folder': Type.Optional(Type.String())
676
+ });
596
677
  const UploadChunkHeaders = Type.Object({
597
678
  'x-upload-filename': Type.String({ minLength: 1, pattern: '\\.mbtiles$' }),
598
679
  'x-chunk-index': Type.Integer({ minimum: 0 }),
@@ -1488,6 +1569,138 @@ const pluginConstructor = (app) => {
1488
1569
  res.status(500).send('Error uploading charts');
1489
1570
  }
1490
1571
  });
1572
+ // Upload a ZIP of charts: the archive is staged in quarantine, every
1573
+ // `.mbtiles` entry is extracted (flattened to basenames), and only then
1574
+ // are the results promoted into the target folder. The ZIP itself is
1575
+ // never kept — it's a transport container, not a chart.
1576
+ //
1577
+ // Same quarantine discipline as `/upload`: an aborted or malformed
1578
+ // request leaves bytes only in the quarantine dir, never in the live
1579
+ // chart library, and the startup sweep reclaims whatever a crash left.
1580
+ router.post('/upload-zip', (req, res) => {
1581
+ try {
1582
+ const bb = Busboy({
1583
+ headers: req.headers,
1584
+ limits: { fileSize: MAX_ZIP_UPLOAD_BYTES, files: 1 }
1585
+ });
1586
+ const basePath = props.chartPath || defaultChartsPath;
1587
+ const quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), 'upload-zip');
1588
+ const fields = {};
1589
+ let stagedZipPath = null;
1590
+ let stagedZipName = '';
1591
+ let writePromise = null;
1592
+ let truncated = false;
1593
+ bb.on('field', (fieldname, value) => {
1594
+ fields[fieldname] = value;
1595
+ });
1596
+ bb.on('file', (_fieldname, file, info) => {
1597
+ const { filename } = info;
1598
+ // Only the first ZIP is taken; anything else is drained so
1599
+ // busboy can still reach 'finish'.
1600
+ if (!/\.zip$/i.test(filename) || stagedZipPath !== null) {
1601
+ file.resume();
1602
+ return;
1603
+ }
1604
+ // The archive's own name never reaches the chart library — the
1605
+ // promoted names come from the entries inside it — so a fixed
1606
+ // staging name sidesteps traversal entirely rather than
1607
+ // validating an attacker-controlled one.
1608
+ stagedZipName = path.basename(filename);
1609
+ const stagedPath = path.join(quarantineDir, 'upload.zip');
1610
+ stagedZipPath = stagedPath;
1611
+ app.debug(`Staging ZIP upload: ${filename} -> ${stagedPath}`);
1612
+ // busboy truncates at the size limit and emits 'limit' rather
1613
+ // than erroring; without this the handler would happily try to
1614
+ // open a half-written archive.
1615
+ file.on('limit', () => {
1616
+ truncated = true;
1617
+ });
1618
+ const writeStream = fs.createWriteStream(stagedPath);
1619
+ file.pipe(writeStream);
1620
+ writePromise = new Promise((resolve, reject) => {
1621
+ writeStream.on('finish', () => resolve());
1622
+ writeStream.on('error', (err) => {
1623
+ app.error(`Error staging ZIP ${filename}: ${err.message}`);
1624
+ reject(err);
1625
+ });
1626
+ });
1627
+ // Observe the rejection now — Promise handling below only
1628
+ // attaches on 'finish', which never fires on an aborted request.
1629
+ writePromise.catch(() => undefined);
1630
+ });
1631
+ bb.on('finish', () => {
1632
+ void (async () => {
1633
+ try {
1634
+ const parsed = parseShape(UploadZipFields, fields, res);
1635
+ if (!parsed) {
1636
+ return;
1637
+ }
1638
+ const { targetFolder = '' } = parsed;
1639
+ if (stagedZipPath === null) {
1640
+ res.status(400).json({ success: false, error: 'No ZIP file uploaded' });
1641
+ return;
1642
+ }
1643
+ if (truncated) {
1644
+ res.status(413).json({
1645
+ success: false,
1646
+ error: `ZIP exceeds the ${MAX_ZIP_UPLOAD_BYTES / (1024 * 1024 * 1024)} GB upload limit`
1647
+ });
1648
+ return;
1649
+ }
1650
+ const promoteTarget = targetFolder && targetFolder !== '/' ? path.join(basePath, targetFolder) : basePath;
1651
+ if (!isWithinBase(promoteTarget, basePath)) {
1652
+ res
1653
+ .status(403)
1654
+ .json({ success: false, error: 'Access denied: Invalid upload path' });
1655
+ return;
1656
+ }
1657
+ if (writePromise) {
1658
+ await writePromise;
1659
+ }
1660
+ // Extract beside the archive, inside the same quarantine dir.
1661
+ await extractAndPromoteZip(stagedZipPath, quarantineDir, stagedZipName, targetFolder, promoteTarget, basePath, res);
1662
+ }
1663
+ catch (error) {
1664
+ app.error('Error completing ZIP upload: ' +
1665
+ (error instanceof Error ? error.message : String(error)));
1666
+ if (!res.headersSent) {
1667
+ // Staging the archive and promoting the extracted charts both
1668
+ // write to disk, so ENOSPC can surface here too — same
1669
+ // reasoning as the extraction branch above.
1670
+ if (error.code === 'ENOSPC') {
1671
+ res.status(507).json({
1672
+ success: false,
1673
+ error: 'Not enough free disk space to complete this upload.'
1674
+ });
1675
+ }
1676
+ else {
1677
+ res.status(500).json({ success: false, error: 'Error completing ZIP upload' });
1678
+ }
1679
+ }
1680
+ }
1681
+ finally {
1682
+ cleanupQuarantineDir(quarantineDir);
1683
+ }
1684
+ })();
1685
+ });
1686
+ bb.on('error', (err) => {
1687
+ app.error('ZIP upload stream error: ' + (err instanceof Error ? err.message : String(err)));
1688
+ cleanupQuarantineDir(quarantineDir);
1689
+ if (!res.headersSent) {
1690
+ res.status(400).json({ success: false, error: 'Malformed upload' });
1691
+ }
1692
+ });
1693
+ req.on('aborted', () => {
1694
+ app.debug('ZIP upload request aborted by client');
1695
+ cleanupQuarantineDir(quarantineDir);
1696
+ });
1697
+ req.pipe(bb);
1698
+ }
1699
+ catch (error) {
1700
+ app.error('Error uploading chart ZIP: ' + (error instanceof Error ? error.message : String(error)));
1701
+ res.status(500).json({ success: false, error: 'Error uploading chart ZIP' });
1702
+ }
1703
+ });
1491
1704
  // Chunked upload for large files — each chunk is a short-lived request that
1492
1705
  // stays well within Node's server.requestTimeout (default 300s in Node 18+).
1493
1706
  router.put('/upload-chunk', (req, res) => {
@@ -1578,6 +1791,190 @@ const pluginConstructor = (app) => {
1578
1791
  res.status(500).json({ error: 'Error in chunked upload' });
1579
1792
  }
1580
1793
  });
1794
+ // Chunked ZIP upload. A 6 GB archive sent as one request cannot finish
1795
+ // inside Node's 300 s `server.requestTimeout` on anything slower than
1796
+ // ~21 MB/s sustained — i.e. it works on wired gigabit and fails on
1797
+ // typical boat WiFi, which is exactly the case this plugin runs in.
1798
+ // Chunks keep each request short, so throughput stops mattering.
1799
+ //
1800
+ // The archive accumulates in a per-upload quarantine dir keyed by the
1801
+ // client-supplied upload id; only the final chunk triggers extraction
1802
+ // and promotion, so an interrupted upload leaves nothing in the chart
1803
+ // library and the startup sweep reclaims the partial archive.
1804
+ router.put('/upload-zip-chunk', (req, res) => {
1805
+ try {
1806
+ const headerFields = {
1807
+ 'x-upload-id': req.headers['x-upload-id'],
1808
+ 'x-upload-filename': req.headers['x-upload-filename'],
1809
+ 'x-chunk-index': req.headers['x-chunk-index'],
1810
+ 'x-total-chunks': req.headers['x-total-chunks'],
1811
+ 'x-target-folder': req.headers['x-target-folder']
1812
+ };
1813
+ const parsed = parseShape(UploadZipChunkHeaders, headerFields, res);
1814
+ if (!parsed) {
1815
+ return;
1816
+ }
1817
+ const uploadId = parsed['x-upload-id'];
1818
+ // The client percent-encodes the filename so non-Latin1 names
1819
+ // survive the header. It's used only for log lines and the success
1820
+ // message — never as a path — but decode defensively anyway.
1821
+ let archiveName;
1822
+ try {
1823
+ archiveName = path.basename(decodeURIComponent(parsed['x-upload-filename']));
1824
+ }
1825
+ catch {
1826
+ archiveName = path.basename(parsed['x-upload-filename']);
1827
+ }
1828
+ const chunkIndex = parsed['x-chunk-index'];
1829
+ const totalChunks = parsed['x-total-chunks'];
1830
+ // Folder names are percent-encoded by the client for the same reason
1831
+ // as the filename. Decoding must happen *before* the containment
1832
+ // check, so `isWithinBase` sees the path we'll actually write to —
1833
+ // and so a folder called `Küste` isn't created as `K%C3%BCste`.
1834
+ // A malformed sequence is rejected rather than silently used raw:
1835
+ // at this point it can only be a client bug or an attack.
1836
+ const rawTargetFolder = parsed['x-target-folder'] ?? '/';
1837
+ let targetFolder;
1838
+ try {
1839
+ targetFolder = decodeURIComponent(rawTargetFolder);
1840
+ }
1841
+ catch {
1842
+ res.status(400).json({ success: false, error: 'Malformed target folder' });
1843
+ return;
1844
+ }
1845
+ const basePath = props.chartPath || defaultChartsPath;
1846
+ const uploadPath = targetFolder && targetFolder !== '/' ? path.join(basePath, targetFolder) : basePath;
1847
+ if (!isWithinBase(uploadPath, basePath)) {
1848
+ res.status(403).json({ success: false, error: 'Access denied: Invalid upload path' });
1849
+ return;
1850
+ }
1851
+ // The upload id only ever names a quarantine directory, never a
1852
+ // path in the chart library. Keyed by id (not filename) so two
1853
+ // uploads of the same archive name can't append into each other.
1854
+ const quarantineDir = makeQuarantineDir(app.getDataDirPath(), `zipchunk-${uploadId}`);
1855
+ const stagedPath = path.join(quarantineDir, 'upload.zip');
1856
+ // The single-request route gets its ceiling from busboy's fileSize
1857
+ // limit; this one has to enforce the same bound itself, or an
1858
+ // authenticated client could append chunks until the data volume is
1859
+ // full. Measure what's already staged rather than trusting
1860
+ // Content-Length, which the client controls.
1861
+ let stagedBytes = 0;
1862
+ if (chunkIndex > 0) {
1863
+ try {
1864
+ stagedBytes = fs.statSync(stagedPath).size;
1865
+ }
1866
+ catch {
1867
+ // No staged archive for this id. Appending would create the file
1868
+ // and start writing at the wrong offset, producing a corrupt
1869
+ // archive that only fails later as "is it a valid ZIP file?".
1870
+ // Say what actually went wrong instead.
1871
+ cleanupQuarantineDir(quarantineDir);
1872
+ res.status(409).json({
1873
+ success: false,
1874
+ error: 'No upload in progress for this id. Restart the upload from the first chunk.'
1875
+ });
1876
+ return;
1877
+ }
1878
+ }
1879
+ if (stagedBytes >= MAX_ZIP_UPLOAD_BYTES) {
1880
+ cleanupQuarantineDir(quarantineDir);
1881
+ res.status(413).json({
1882
+ success: false,
1883
+ error: `ZIP exceeds the ${MAX_ZIP_UPLOAD_BYTES / (1024 * 1024 * 1024)} GB upload limit`
1884
+ });
1885
+ return;
1886
+ }
1887
+ const writeStream = fs.createWriteStream(stagedPath, {
1888
+ flags: chunkIndex === 0 ? 'w' : 'a'
1889
+ });
1890
+ // Abort mid-chunk if this request would carry the staged archive past
1891
+ // the ceiling, so an oversize upload is stopped as it streams rather
1892
+ // than after it has already landed on disk.
1893
+ let overLimit = false;
1894
+ let received = 0;
1895
+ req.on('data', (chunk) => {
1896
+ received += chunk.length;
1897
+ if (!overLimit && stagedBytes + received > MAX_ZIP_UPLOAD_BYTES) {
1898
+ overLimit = true;
1899
+ req.unpipe(writeStream);
1900
+ writeStream.destroy();
1901
+ cleanupQuarantineDir(quarantineDir);
1902
+ if (!res.headersSent) {
1903
+ res.status(413).json({
1904
+ success: false,
1905
+ error: `ZIP exceeds the ${MAX_ZIP_UPLOAD_BYTES / (1024 * 1024 * 1024)} GB upload limit`
1906
+ });
1907
+ }
1908
+ }
1909
+ });
1910
+ // An abandoned chunked upload would otherwise keep its partial
1911
+ // archive until the next server start, since makeQuarantineDir
1912
+ // registers the dir as active and the sweep skips live entries.
1913
+ req.on('aborted', () => {
1914
+ app.debug(`ZIP chunk upload aborted for ${archiveName}`);
1915
+ writeStream.destroy();
1916
+ cleanupQuarantineDir(quarantineDir);
1917
+ });
1918
+ req.pipe(writeStream);
1919
+ writeStream.on('finish', () => {
1920
+ void (async () => {
1921
+ try {
1922
+ if (overLimit) {
1923
+ return;
1924
+ }
1925
+ if (chunkIndex + 1 < totalChunks) {
1926
+ app.debug(`ZIP chunk ${chunkIndex + 1}/${totalChunks} for ${archiveName}`);
1927
+ res.json({ received: chunkIndex, total: totalChunks });
1928
+ return;
1929
+ }
1930
+ app.debug(`Final ZIP chunk for ${archiveName}, extracting`);
1931
+ try {
1932
+ await extractAndPromoteZip(stagedPath, quarantineDir, archiveName, targetFolder, uploadPath, basePath, res);
1933
+ }
1934
+ finally {
1935
+ cleanupQuarantineDir(quarantineDir);
1936
+ }
1937
+ }
1938
+ catch (error) {
1939
+ app.error(`Error finalizing chunked ZIP upload: ${error instanceof Error ? error.message : String(error)}`);
1940
+ if (!res.headersSent) {
1941
+ if (error.code === 'ENOSPC') {
1942
+ res.status(507).json({
1943
+ success: false,
1944
+ error: 'Not enough free disk space to complete this upload.'
1945
+ });
1946
+ }
1947
+ else {
1948
+ res.status(500).json({ success: false, error: 'Error finalizing upload' });
1949
+ }
1950
+ }
1951
+ }
1952
+ })();
1953
+ });
1954
+ writeStream.on('error', (err) => {
1955
+ // Destroying the stream on an over-limit abort lands here too; that
1956
+ // path has already answered the request.
1957
+ if (overLimit) {
1958
+ return;
1959
+ }
1960
+ app.error(`Error writing ZIP chunk for ${archiveName}: ${err.message}`);
1961
+ cleanupQuarantineDir(quarantineDir);
1962
+ if (!res.headersSent) {
1963
+ const code = err.code;
1964
+ res.status(code === 'ENOSPC' ? 507 : 500).json({
1965
+ success: false,
1966
+ error: code === 'ENOSPC'
1967
+ ? 'Not enough free disk space to receive this archive.'
1968
+ : 'Error writing chunk'
1969
+ });
1970
+ }
1971
+ });
1972
+ }
1973
+ catch (error) {
1974
+ app.error('Error in chunked ZIP upload: ' + (error instanceof Error ? error.message : String(error)));
1975
+ res.status(500).json({ success: false, error: 'Error in chunked ZIP upload' });
1976
+ }
1977
+ });
1581
1978
  // ---- Chart Catalog API routes ----
1582
1979
  router.get('/catalog-registry', (_req, res) => {
1583
1980
  try {