diff --git a/package-lock.json b/package-lock.json index 09bd783d..09c9df83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,9 +44,11 @@ "html-webpack-plugin": "^5.6.3", "identity-obj-proxy": "^3.0.0", "joi": "^18.0.1", + "jszip": "^3.10.1", "magic-comments-loader": "^2.1.4", "mini-css-extract-plugin": "^2.9.2", "moment-timezone": "^0.6.0", + "multiparty": "^4.2.3", "ngr-to-bng": "0.0.1", "node-cache": "^5.1.2", "nunjucks": "^3.2.4", @@ -56,6 +58,7 @@ "sass": "^1.93.2", "sass-loader": "^16.0.3", "sass-mq": "^7.0.0", + "shpjs": "^6.2.0", "url-loader": "^4.1.1", "webpack": "^5.104.1", "webpack-bundle-analyzer": "^4.10.2", @@ -237,7 +240,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2156,7 +2158,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2202,7 +2203,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -10450,7 +10450,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -11462,7 +11461,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11546,7 +11544,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12475,7 +12472,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -12521,6 +12517,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/but-unzip": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/but-unzip/-/but-unzip-0.1.10.tgz", + "integrity": "sha512-hLfQ9WlUimmv/okzsRl6AYG3Ew5HNWhWgUslSR93FsDdeL0MAoQvmC/BJfs35lqEAO5t/QD7Y4vCFcPJtijt3A==", + "license": "Apache-2.0" + }, "node_modules/bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -14458,7 +14460,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -15826,7 +15827,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.1.tgz", "integrity": "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", @@ -20305,7 +20305,6 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -20458,6 +20457,60 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -20548,6 +20601,21 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lie/node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -20571,7 +20639,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -20840,6 +20907,7 @@ "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/geojson-types": "^1.0.2", @@ -20872,49 +20940,57 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/mapbox-gl/node_modules/@mapbox/tiny-sdf": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/mapbox-gl/node_modules/@mapbox/unitbezier": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/mapbox-gl/node_modules/geojson-vt": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/mapbox-gl/node_modules/kdbush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/mapbox-gl/node_modules/potpack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/mapbox-gl/node_modules/quickselect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/mapbox-gl/node_modules/supercluster": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", "license": "ISC", + "peer": true, "dependencies": { "kdbush": "^3.0.0" } @@ -21643,6 +21719,12 @@ "node": ">= 0.6" } }, + "node_modules/mgrs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz", + "integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -23638,6 +23720,54 @@ "multicast-dns": "cli.js" } }, + "node_modules/multiparty": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/multiparty/-/multiparty-4.2.3.tgz", + "integrity": "sha512-Ak6EUJZuhGS8hJ3c2fY6UW5MbkGUPMBEGd13djUzoY/BHqV/gTuFWtC6IuVA7A2+v3yjBS6c4or50xhzTQZImQ==", + "license": "MIT", + "dependencies": { + "http-errors": "~1.8.1", + "safe-buffer": "5.2.1", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/multiparty/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multiparty/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multiparty/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/murmurhash-js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", @@ -24558,6 +24688,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parsedbf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parsedbf/-/parsedbf-2.0.0.tgz", + "integrity": "sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -25065,7 +25201,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -26539,6 +26674,19 @@ ], "license": "MIT" }, + "node_modules/proj4": { + "version": "2.20.8", + "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.8.tgz", + "integrity": "sha512-1C8sfT4xY4PAPwk0MroFBTGF4R4bzDXdmPQTGYVLsoNssrZ9odzObxS2dTeGBty8jW8KO7h16C1Hs2JP+ctfFw==", + "license": "MIT", + "dependencies": { + "mgrs": "1.0.0", + "wkt-parser": "^1.5.5" + }, + "funding": { + "url": "https://github.com/sponsors/ahocevar" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -26734,6 +26882,15 @@ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -26838,7 +26995,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -28069,7 +28225,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -28405,6 +28560,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -28462,6 +28623,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shpjs": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz", + "integrity": "sha512-8cR/RKYHQepmVyBMtzZQ+1bnSbWrtLXS6aoEJmpUlOSHtSUddterebVxYmIWq2g9kOEX9jm2kjHiikyPX7cNQA==", + "license": "MIT", + "dependencies": { + "but-unzip": "^0.1.4", + "parsedbf": "^2.0.0", + "proj4": "^2.1.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -29540,7 +29712,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -29971,6 +30142,18 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -30663,7 +30846,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -31279,6 +31461,15 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "license": "MIT" }, + "node_modules/wkt-parser": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.5.tgz", + "integrity": "sha512-/zMYi94/7D7fxcOSlVmWn6vnOMj3Gq5d1xvVjaYOS9n6h0qOJ4I7YYVxBWYcH1vq9+suhqzXkn05Yx47zQNUIA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ahocevar" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index f9688081..35b6b358 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,10 @@ "postinstall": "npm run build", "kill": "sudo kill -9 $(sudo lsof -t -i:3000)", "increment-version": "npm version prerelease --preid=pre -m \"Set Version to %s\"", - "set-config": "run(){ cp ./config/server.$1.json ./config/server.json; }; run" + "set-config": "run(){ cp ./config/server.$1.json ./config/server.json; }; run", + "build:container": "docker build -t fmp-app .", + "start:container": "docker --context desktop-linux run --platform linux/x86_64 --env-file=.env -p 3001:3001 fmp-app", + "stop:container": "docker kill $(docker ps -q -f ancestor=fmp-app)" }, "author": "defra", "license": "ISC", @@ -62,9 +65,11 @@ "html-webpack-plugin": "^5.6.3", "identity-obj-proxy": "^3.0.0", "joi": "^18.0.1", + "jszip": "^3.10.1", "magic-comments-loader": "^2.1.4", "mini-css-extract-plugin": "^2.9.2", "moment-timezone": "^0.6.0", + "multiparty": "^4.2.3", "ngr-to-bng": "0.0.1", "node-cache": "^5.1.2", "nunjucks": "^3.2.4", @@ -74,6 +79,7 @@ "sass": "^1.93.2", "sass-loader": "^16.0.3", "sass-mq": "^7.0.0", + "shpjs": "^6.2.0", "url-loader": "^4.1.1", "webpack": "^5.104.1", "webpack-bundle-analyzer": "^4.10.2", diff --git a/server/constants.js b/server/constants.js index 9ddb05ae..67df7404 100644 --- a/server/constants.js +++ b/server/constants.js @@ -22,6 +22,7 @@ const FEEDBACK = 'feedback' const ORDER_NOT_SUBMITTED = 'order-not-submitted' const OS_TERMS = 'os-terms' const TERMS_AND_CONDITIONS = 'terms-and-conditions' +const UPLOAD = 'upload' const views = { HOME, @@ -42,7 +43,8 @@ const views = { FEEDBACK, ORDER_NOT_SUBMITTED, OS_TERMS, - TERMS_AND_CONDITIONS + TERMS_AND_CONDITIONS, + UPLOAD } const routes = { diff --git a/server/plugins/router.js b/server/plugins/router.js index 8155519b..5dec9f22 100644 --- a/server/plugins/router.js +++ b/server/plugins/router.js @@ -27,7 +27,8 @@ const routes = [].concat( require('../routes/public'), require('../routes/results'), require('../routes/terms-and-conditions'), - require('../routes/triage') + require('../routes/triage'), + require('../routes/upload') ) module.exports = { diff --git a/server/routes/__tests__/upload.spec.js b/server/routes/__tests__/upload.spec.js new file mode 100644 index 00000000..816dffe4 --- /dev/null +++ b/server/routes/__tests__/upload.spec.js @@ -0,0 +1,106 @@ +const constants = require('../../constants') +const { + submitGetRequest, + submitPostRequest, + submitPostRequestExpectHandledError, + submitPostRequestExpectServiceError +} = require('../../__test-helpers__/server') +const mockPart = { filename: 'test.zip' } +const shp = require('shpjs').default +const { extractProjectionFiles } = require('../../services/zip-helper') +const setupZipMocks = (geojson = validGeoJSON) => { + extractProjectionFiles.mockResolvedValue(new ArrayBuffer(8)) + shp.mockResolvedValue(geojson) +} + +jest.mock('../../services/zip-helper', () => ({ + extractProjectionFiles: jest.fn() +})) +jest.mock('shpjs', () => ({ + __esModule: true, + default: jest.fn() +}), { virtual: true }) +jest.mock('../../services/validate-uploaded-shape-file', () => ({ + validateShapeFile: jest.fn(), + validateGeoJSON: jest.fn() +})) +jest.mock('../../services/file-helper', () => ({ + getFile: jest.fn(), + streamToBuffer: jest.fn() +})) + +const { getFile, streamToBuffer } = require('../../services/file-helper') +const { validateShapeFile, validateGeoJSON } = require('../../services/validate-uploaded-shape-file') + +const url = constants.routes.UPLOAD + +const validGeoJSON = { + features: [ + { + geometry: { + type: 'Polygon', + coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] + } + } + ] +} + +beforeEach(() => { + getFile.mockResolvedValue(mockPart) + streamToBuffer.mockResolvedValue(Buffer.from('fake zip data')) + validateShapeFile.mockReturnValue([]) + validateGeoJSON.mockReturnValue([]) + setupZipMocks() +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('Upload route', () => { + describe('GET', () => { + it('should return the upload view', async () => { + await submitGetRequest({ url }, 'Upload a boundary') + }) + }) + + describe('POST', () => { + describe('file validation', () => { + it('should return an error for an invalid file extension', async () => { + validateShapeFile.mockReturnValue([{ text: 'Only upload a GeoJSON file (.geojson), Geopackage (.gpkg) or Shape files (.zip)', href: '#boundary' }]) + await submitPostRequestExpectHandledError( + { url }, + 'Only upload a GeoJSON file (.geojson), Geopackage (.gpkg) or Shape files (.zip)' + ) + }) + + describe('GeoJSON validation', () => { + it('should return an error if geojson is invalid', async () => { + validateGeoJSON.mockReturnValue([{ text: 'Only upload a GeoJSON with a single feature.', href: '#boundary' }]) + await submitPostRequestExpectHandledError( + { url }, + 'Only upload a GeoJSON with a single feature.' + ) + }) + }) + }) + + describe('successful upload', () => { + it('should redirect to the map route with the polygon coordinates', async () => { + setupZipMocks() + const response = await submitPostRequest({ url }) + const expectedPolygon = JSON.stringify( + validGeoJSON.features[0].geometry.coordinates[0] + ) + expect(response.headers.location).toBe(`${constants.routes.MAP}?polygon=${expectedPolygon}`) + }) + }) + + describe('error handling', () => { + it('should return a service error if getFile fails', async () => { + getFile.mockRejectedValue(new Error('Form parse error')) + await submitPostRequestExpectServiceError({ url }) + }) + }) + }) +}) diff --git a/server/routes/upload.js b/server/routes/upload.js new file mode 100644 index 00000000..32fb2c4d --- /dev/null +++ b/server/routes/upload.js @@ -0,0 +1,58 @@ +const { getFile, streamToBuffer } = require('../services/file-helper') +const constants = require('../constants') +const { validateShapeFile, validateGeoJSON } = require('../services/validate-uploaded-shape-file') +const fiftyMbNumeric = 50 +const fiftyMbInBytes = fiftyMbNumeric * 1024 * 1024 +const { extractProjectionFiles } = require('../services/zip-helper') + +const handlers = { + get: async (_request, h) => h.view(constants.views.UPLOAD), + post: async (request, h) => { + const file = await getFile(request) + const errorSummary = validateShapeFile(file) + if (errorSummary.length > 0) { + return h.view(constants.views.UPLOAD, { + errorSummary + }) + } + const { default: shp } = await import('shpjs') // needs to be imported here as only ESM can be used with shpjs + const buffer = await streamToBuffer(file) + const modifiedBuffer = await extractProjectionFiles(buffer) + const geojson = await shp(modifiedBuffer) + const boundaryErrorSummary = validateGeoJSON(geojson) + + if (boundaryErrorSummary.length > 0) { + return h.view(constants.views.UPLOAD, { + errorSummary: boundaryErrorSummary + }) + } + + const polygon = geojson.features[0].geometry.coordinates[0] + + return h.redirect(`${constants.routes.MAP}?polygon=${JSON.stringify(polygon)}`) + } +} + +module.exports = [ + { + method: 'GET', + path: constants.routes.UPLOAD, + options: { + description: 'Upload Page', + handler: handlers.get + } + }, + { + method: 'POST', + path: constants.routes.UPLOAD, + handler: handlers.post, + options: { + payload: { + maxBytes: fiftyMbInBytes, + multipart: true, + output: 'stream', + parse: false + } + } + } +] diff --git a/server/services/__tests__/file-helper.spec.js b/server/services/__tests__/file-helper.spec.js new file mode 100644 index 00000000..f756ee53 --- /dev/null +++ b/server/services/__tests__/file-helper.spec.js @@ -0,0 +1,94 @@ +const multiparty = require('multiparty') +const { getFile, streamToBuffer } = require('../file-helper') + +jest.mock('multiparty') + +let mockForm +let mockPart + +beforeEach(() => { + mockPart = { + filename: 'test.zip', + [Symbol.asyncIterator]: async function * () { + yield Buffer.from('fake zip data') + } + } + + mockForm = { + on: jest.fn(), + parse: jest.fn() + } + + multiparty.Form.mockImplementation(() => mockForm) + + mockForm.on.mockImplementation((event, handler) => { + if (event === 'part') { + setImmediate(() => handler(mockPart)) + } + }) +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('getFile', () => { + it('should resolve with the file part when a file is received', async () => { + const result = await getFile({ raw: { req: {} } }) + expect(result).toBe(mockPart) + }) + + it('should call form.parse with the raw request', async () => { + const mockRawReq = {} + await getFile({ raw: { req: mockRawReq } }) + expect(mockForm.parse).toHaveBeenCalledWith(mockRawReq) + }) + + it('should reject if a non-file part is received', async () => { + mockForm.on.mockImplementation((event, handler) => { + if (event === 'part') setImmediate(() => handler({ filename: null })) + }) + await expect(getFile({ raw: { req: {} } })).rejects.toThrow('Non file received') + }) + + it('should reject if multiparty emits an error', async () => { + mockForm.on.mockImplementation((event, handler) => { + if (event === 'error') setImmediate(() => handler(new Error('Form parse error'))) + }) + await expect(getFile({ raw: { req: {} } })).rejects.toThrow('Form parse error') + }) + + it('should preserve the original error when multiparty emits an error', async () => { + const originalError = new Error('Form parse error') + mockForm.on.mockImplementation((event, handler) => { + if (event === 'error') setImmediate(() => handler(originalError)) + }) + await expect(getFile({ raw: { req: {} } })).rejects.toBe(originalError) + }) +}) + +describe('streamToBuffer', () => { + it('should convert a stream to a buffer', async () => { + const result = await streamToBuffer(mockPart) + expect(result).toEqual(Buffer.from('fake zip data')) + }) + + it('should handle a stream with multiple chunks', async () => { + const multiChunkStream = { + [Symbol.asyncIterator]: async function * () { + yield Buffer.from('chunk one ') + yield Buffer.from('chunk two') + } + } + const result = await streamToBuffer(multiChunkStream) + expect(result).toEqual(Buffer.from('chunk one chunk two')) + }) + + it('should return an empty buffer for an empty stream', async () => { + const emptyStream = { + [Symbol.asyncIterator]: async function * () { } + } + const result = await streamToBuffer(emptyStream) + expect(result).toEqual(Buffer.alloc(0)) + }) +}) diff --git a/server/services/__tests__/validate-uploaded-shape-file.spec.js b/server/services/__tests__/validate-uploaded-shape-file.spec.js new file mode 100644 index 00000000..fce2fef1 --- /dev/null +++ b/server/services/__tests__/validate-uploaded-shape-file.spec.js @@ -0,0 +1,88 @@ +const { validateShapeFile, validateGeoJSON } = require('../validate-uploaded-shape-file') + +describe('validateShapeFile', () => { + it('should return no errors for a .zip file', () => { + const result = validateShapeFile({ filename: 'test.zip' }) + expect(result).toEqual([]) + }) + + it('should return no errors for a .gpkg file', () => { + const result = validateShapeFile({ filename: 'test.gpkg' }) + expect(result).toEqual([]) + }) + + it('should return no errors for a .geojson file', () => { + const result = validateShapeFile({ filename: 'test.geojson' }) + expect(result).toEqual([]) + }) + + it('should return an error for an invalid file extension', () => { + const result = validateShapeFile({ filename: 'test.txt' }) + expect(result).toEqual([{ + text: 'Only upload a GeoJSON file (.geojson), Geopackage (.gpkg) or Shape files (.zip)', + href: '#boundary' + }]) + }) + + it('should return an error for a file with no extension', () => { + const result = validateShapeFile({ filename: 'test' }) + expect(result).toEqual([{ + text: 'Only upload a GeoJSON file (.geojson), Geopackage (.gpkg) or Shape files (.zip)', + href: '#boundary' + }]) + }) +}) + +describe('validateGeoJSON', () => { + it('should return no errors for a valid GeoJSON', () => { + const result = validateGeoJSON({ + features: [{ geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] } }] + }) + expect(result).toEqual([]) + }) + + it('should return an error if geojson is null', () => { + const result = validateGeoJSON(null) + expect(result).toEqual([{ + text: 'Only upload a GeoJSON with a single feature.', + href: '#boundary' + }]) + }) + + it('should return an error if geojson has no features', () => { + const result = validateGeoJSON({ features: [] }) + expect(result).toEqual([{ + text: 'Only upload a GeoJSON with a single feature.', + href: '#boundary' + }]) + }) + + it('should return an error if geojson has multiple features', () => { + const result = validateGeoJSON({ + features: [ + { geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] } }, + { geometry: { type: 'Polygon', coordinates: [[[2, 2], [3, 2], [3, 3], [2, 2]]] } } + ] + }) + expect(result).toEqual([{ + text: 'Only upload a GeoJSON with a single feature.', + href: '#boundary' + }]) + }) + + it('should return an error if the feature is not a Polygon', () => { + const result = validateGeoJSON({ + features: [{ geometry: { type: 'LineString', coordinates: [] } }] + }) + expect(result).toEqual([{ + text: 'Feature must be a single polygon.', + href: '#boundary' + }]) + }) + + it('should not check geometry type if feature count is invalid', () => { + const result = validateGeoJSON({ features: [] }) + expect(result).toHaveLength(1) + expect(result[0].text).toContain('single feature') + }) +}) diff --git a/server/services/__tests__/zip-helper.spec.js b/server/services/__tests__/zip-helper.spec.js new file mode 100644 index 00000000..e4de1a2a --- /dev/null +++ b/server/services/__tests__/zip-helper.spec.js @@ -0,0 +1,65 @@ +const JSZip = require('jszip') +const { extractProjectionFiles } = require('../zip-helper') + +jest.mock('jszip') + +const mockBuffer = Buffer.from('fake zip data') +const mockArrayBuffer = new ArrayBuffer(8) + +let mockZip + +beforeEach(() => { + mockZip = { + files: { + 'shape.shp': {}, + 'shape.prj': {}, + 'shape.PRJ': {} + }, + remove: jest.fn(), + generateAsync: jest.fn().mockResolvedValue(mockArrayBuffer) + } + JSZip.loadAsync = jest.fn().mockResolvedValue(mockZip) +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('extractProjectionFiles', () => { + it('should load the buffer as a zip', async () => { + await extractProjectionFiles(mockBuffer) + expect(JSZip.loadAsync).toHaveBeenCalledWith(mockBuffer) + }) + + it('should remove .prj files from the zip', async () => { + await extractProjectionFiles(mockBuffer) + expect(mockZip.remove).toHaveBeenCalledWith('shape.prj') + }) + + it('should remove .prj files with uppercase extension', async () => { + await extractProjectionFiles(mockBuffer) + expect(mockZip.remove).toHaveBeenCalledWith('shape.PRJ') + }) + + it('should not remove non .prj files', async () => { + await extractProjectionFiles(mockBuffer) + expect(mockZip.remove).not.toHaveBeenCalledWith('shape.shp') + }) + + it('should not call remove if there are no .prj files', async () => { + mockZip.files = { 'shape.shp': {} } + await extractProjectionFiles(mockBuffer) + expect(mockZip.remove).not.toHaveBeenCalled() + }) + + it('should return the result of generateAsync', async () => { + const result = await extractProjectionFiles(mockBuffer) + expect(mockZip.generateAsync).toHaveBeenCalledWith({ type: 'arraybuffer' }) + expect(result).toBe(mockArrayBuffer) + }) + + it('should throw if JSZip fails to load the buffer', async () => { + JSZip.loadAsync = jest.fn().mockRejectedValue(new Error('Invalid zip')) + await expect(extractProjectionFiles(mockBuffer)).rejects.toThrow('Invalid zip') + }) +}) diff --git a/server/services/file-helper.js b/server/services/file-helper.js new file mode 100644 index 00000000..5e3fa8ad --- /dev/null +++ b/server/services/file-helper.js @@ -0,0 +1,29 @@ +const multiparty = require('multiparty') + +const getFile = (request) => { + const form = new multiparty.Form() + return new Promise((resolve, reject) => { + form.on('part', (part) => { + if (part.filename) { + console.log(`file uploaded: ${part.filename}`) + resolve(part) + } else { + reject(new Error('Non file received')) + } + }) + form.on('error', (err) => { + reject(err) + }) + form.parse(request.raw.req) + }) +} + +const streamToBuffer = async (stream) => { + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + return Buffer.concat(chunks) +} + +module.exports = { getFile, streamToBuffer } diff --git a/server/services/validate-uploaded-shape-file.js b/server/services/validate-uploaded-shape-file.js new file mode 100644 index 00000000..7699e8cc --- /dev/null +++ b/server/services/validate-uploaded-shape-file.js @@ -0,0 +1,35 @@ +const validateShapeFile = (file) => { + const errorSummary = [] + const fileExt = file.filename.split('.').pop() + if (fileExt !== 'zip' && fileExt !== 'gpkg' && fileExt !== 'geojson') { + errorSummary.push({ + text: 'Only upload a GeoJSON file (.geojson), Geopackage (.gpkg) or Shape files (.zip)', + href: '#boundary' + }) + } + + return errorSummary +} + +const validateGeoJSON = (geojson) => { + const errorSummary = [] + + if (geojson?.features?.length !== 1) { + errorSummary.push({ + text: 'Only upload a GeoJSON with a single feature.', + href: '#boundary', + }) + return errorSummary + } + + if (geojson.features[0].geometry.type !== 'Polygon') { + errorSummary.push({ + text: 'Feature must be a single polygon.', + href: '#boundary', + }) + } + + return errorSummary +} + +module.exports = { validateShapeFile, validateGeoJSON } diff --git a/server/services/zip-helper.js b/server/services/zip-helper.js new file mode 100644 index 00000000..455e614d --- /dev/null +++ b/server/services/zip-helper.js @@ -0,0 +1,15 @@ +const JSZip = require('jszip') + +const extractProjectionFiles = async (buffer) => { + const zip = await JSZip.loadAsync(buffer) + + // Remove .prj files from the zip so we do not convert + // we will only allow OSTN15 OS coordinates. + Object.keys(zip.files) + .filter(name => name.toLowerCase().endsWith('.prj')) + .forEach(name => zip.remove(name)) + + return zip.generateAsync({ type: 'arraybuffer' }) +} + +module.exports = { extractProjectionFiles } diff --git a/server/views/location.html b/server/views/location.html index 26adca18..13006e6e 100644 --- a/server/views/location.html +++ b/server/views/location.html @@ -141,6 +141,10 @@ ], errorMessage: findErrorMessageById(errorSummary, "findby") })}} + +

+ Upload a boundary +

Skip to map

diff --git a/server/views/upload.html b/server/views/upload.html new file mode 100644 index 00000000..ff2aba8b --- /dev/null +++ b/server/views/upload.html @@ -0,0 +1,44 @@ +{% extends "layout.html" %} +{% from "fieldset/macro.njk" import govukFieldset %} +{% from "file-upload/macro.njk" import govukFileUpload %} +{% from "error-summary/macro.njk" import govukErrorSummary %} + +{% set pageTitle = 'Upload a boundary' %} + +{% block content %} + +
+
+
+ {% if errorSummary.length > 0 %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: errorSummary + }) + }} + {% endif %} + {{ govukFieldset({ + legend: { + text: pageTitle, + classes: "govuk-fieldset__legend--xl", + isPageHeading: true + } + }) }} + {{ govukFileUpload({ + id: "boundary", + name: "boundary", + hint: { + text: "GeoJSON (.geojson), Geopackage (.gpkg) or Shape files (.zip) - max size 50MB - EPSG:27700, EPSG:4326, EPSG:3857" + }, + errorMessage: findErrorMessageById(errorSummary, "boundary") + }) }} + + {{ govukButton({ + text: "Continue", + preventDoubleClick: true + }) }} +
+
+
+ +{% endblock %} \ No newline at end of file