signalk-charts-provider-simple 2.4.2 → 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 +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +397 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/custom-catalog-download.d.ts +6 -5
- package/dist/utils/custom-catalog-download.d.ts.map +1 -1
- package/dist/utils/custom-catalog-download.js +7 -31
- package/dist/utils/custom-catalog-download.js.map +1 -1
- package/dist/utils/zip-extract.d.ts +73 -0
- package/dist/utils/zip-extract.d.ts.map +1 -0
- package/dist/utils/zip-extract.js +209 -0
- package/dist/utils/zip-extract.js.map +1 -0
- package/package.json +2 -2
- package/public/js/globals.d.ts +3 -0
- package/public/js/manage-charts-enhanced.js +283 -2
- package/public/js/manage-charts-enhanced.ts +336 -3
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
|
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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 {
|