diff --git a/package-lock.json b/package-lock.json index 3831e69f..76ec4295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "smart-incident-reporting", - "version": "2.3.0", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smart-incident-reporting", - "version": "2.3.0", + "version": "2.5.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@azure/identity": "^4.5.0", "@azure/service-bus": "^7.9.5", + "@azure/storage-blob": "^12.31.0", "@friendlycaptcha/sdk": "^0.1.22", "@hapi/boom": "^10.0.1", "@hapi/catbox-redis": "^7.0.2", @@ -29,6 +30,8 @@ "copy-webpack-plugin": "^13.0.0", "cron-parser": "^4.9.0", "css-loader": "^7.1.2", + "formidable": "^3.5.4", + "fs": "^0.0.1-security", "govuk-frontend": "^5.11.1", "hapi-pino": "^12.1.0", "joi": "^17.13.3", @@ -38,6 +41,7 @@ "postcode-validator": "^3.10.2", "proj4": "^2.15.0", "sass": "^1.89.1", + "sharp": "^0.34.5", "style-loader": "^4.0.0", "webpack": "^5.97.1", "webpack-cli": "^5.1.4" @@ -106,30 +110,61 @@ } }, "node_modules/@azure/core-auth": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.9.0.tgz", - "integrity": "sha512-FPwHpZywuyasDSLMqJ6fhbOK3TqUdviZNF8OqRGA4W5Ewib2lEEZ+pBsYcBa88B2NGO/SEnYPGhyBqNlE8ilSw==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", "license": "MIT", "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.11.0", + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/core-client": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.9.4.tgz", - "integrity": "sha512-f7IxTD15Qdux30s2qFARH+JxgwxWLG2Rlr4oSkPGuLWm+1p5y1+C04XGLA0vmX6EtqfutmjvpNmAfgwVIS5hpw==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.2.tgz", + "integrity": "sha512-Tf6ltdKzOJEgxZeWLCjMxrxbodB/ZeCbzzA1A2qHbhzAjzjHoBVSUeSl/baT/oHAxhc4qdqVaDKnc2+iE932gw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.4.0", - "@azure/core-rest-pipeline": "^1.20.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.6.1", + "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" }, @@ -150,47 +185,47 @@ } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.20.0.tgz", - "integrity": "sha512-ASoP8uqZBS3H/8N8at/XwFr6vYrRP3syTK0EUjDXQy0Y1/AUS+QeIRThKmTNJO2RggvBBxaXDPM7YoIwDGeA0g==", + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", + "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", "license": "MIT", "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.8.0", - "@azure/core-tracing": "^1.0.1", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@typespec/ts-http-runtime": "^0.2.2", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/core-tracing": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.2.0.tgz", - "integrity": "sha512-UKTiEJPkWcESPYJz3X5uKRYyOcJD+4nYph+KpfdPRnQJVrZfk0KJgdnaAWKfhsBBtAf/D58Az4AvCJEmWgIBAg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", "license": "MIT", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/core-util": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.12.0.tgz", - "integrity": "sha512-13IyjTQgABPARvG90+N2dXpC+hwp466XCdQXPCRlbWHgd3SJd5Q1VvaBGv6k1BIa4MQm6hAF1UBU1m8QUxV8sQ==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", "license": "MIT", "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@typespec/ts-http-runtime": "^0.2.2", + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/core-xml": { @@ -229,16 +264,16 @@ } }, "node_modules/@azure/logger": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.2.0.tgz", - "integrity": "sha512-0hKEzLhpw+ZTAfNJyRrn6s+V0nDWzXk9OjBr2TiGIu0OfMr5s2V4FpKLTAK3Ca5r5OKLbf4hkOGDPyiRjie/jA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", "license": "MIT", "dependencies": { - "@typespec/ts-http-runtime": "^0.2.2", + "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/msal-browser": { @@ -317,6 +352,51 @@ "node": ">=12.0.0" } }, + "node_modules/@azure/storage-blob": { + "version": "12.31.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.31.0.tgz", + "integrity": "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.3", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/core-xml": "^1.4.5", + "@azure/logger": "^1.1.4", + "@azure/storage-common": "^12.3.0", + "events": "^3.0.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/storage-common": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.3.0.tgz", + "integrity": "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.1.4", + "events": "^3.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2040,6 +2120,16 @@ "node": ">=10.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -2607,6 +2697,471 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", @@ -2990,6 +3545,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3028,6 +3595,15 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -3561,9 +4137,9 @@ "license": "MIT" }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.2.2.tgz", - "integrity": "sha512-Gz/Sm64+Sq/vklJu1tt9t+4R2lvnud8NbTD/ZfpZtMiUX7YeVpCA8j6NSW8ptwcoLL+NmYANwqP8DV0q/bwl2w==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", + "integrity": "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==", "license": "MIT", "dependencies": { "http-proxy-agent": "^7.0.0", @@ -3571,7 +4147,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@ungap/structured-clone": { @@ -3844,9 +4420,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" @@ -5301,6 +5877,16 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -6645,6 +7231,29 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==", + "license": "ISC" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9506,7 +10115,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -10848,6 +11456,71 @@ "node": ">=8" } }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12261,7 +12934,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index 5f2eea76..83539e3a 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "@azure/identity": "^4.5.0", "@azure/service-bus": "^7.9.5", + "@azure/storage-blob": "^12.31.0", "@friendlycaptcha/sdk": "^0.1.22", "@hapi/boom": "^10.0.1", "@hapi/catbox-redis": "^7.0.2", @@ -52,7 +53,11 @@ "ajv-formats": "^3.0.1", "axios": "1.12.2", "blipp": "^4.0.2", + "copy-webpack-plugin": "^13.0.0", "cron-parser": "^4.9.0", + "css-loader": "^7.1.2", + "formidable": "^3.5.4", + "fs": "^0.0.1-security", "govuk-frontend": "^5.11.1", "hapi-pino": "^12.1.0", "joi": "^17.13.3", @@ -61,22 +66,21 @@ "ol": "^10.3.1", "postcode-validator": "^3.10.2", "proj4": "^2.15.0", - "css-loader": "^7.1.2", "sass": "^1.89.1", + "sharp": "^0.34.5", "style-loader": "^4.0.0", "webpack": "^5.97.1", - "copy-webpack-plugin": "^13.0.0", "webpack-cli": "^5.1.4" }, "devDependencies": { "@babel/preset-env": "^7.26.0", "@hapi/catbox-memory": "^6.0.2", "concurrently": "^9.1.2", + "dotenv": "^16.5.0", "husky": "^9.1.7", "jest": "^29.7.0", "jest-junit": "^16.0.0", "node-html-parser": "^6.1.13", - "dotenv": "^16.5.0", "nodemon": "^3.1.10", "standard": "^17.1.2" }, diff --git a/server/plugins/logging.js b/server/plugins/logging.js index 4f8e07c6..3e2ef7d0 100644 --- a/server/plugins/logging.js +++ b/server/plugins/logging.js @@ -7,7 +7,7 @@ export default { logPayload: true, level: config.logLevel, redact: { - paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers'], + paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers', 'payload.fileUpload1'], remove: true }, ignorePaths: [ diff --git a/server/routes/__tests__/add-a-photo.spec.js b/server/routes/__tests__/add-a-photo.spec.js new file mode 100644 index 00000000..a1cdb8bc --- /dev/null +++ b/server/routes/__tests__/add-a-photo.spec.js @@ -0,0 +1,203 @@ +import { submitGetRequest, submitPostRequest } from '../../__test-helpers__/server.js' +import constants from '../../utils/constants.js' +import { BlobServiceClient } from '@azure/storage-blob' +import fs from 'node:fs' +import FormData from 'form-data' +import * as addPhoto from '../add-a-photo.js' + +jest.mock('@azure/storage-blob', () => ({ + BlobServiceClient: jest.fn(), + StorageSharedKeyCredential: jest.fn() +})) + +const mockValidPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+5e0AAAAASUVORK5CYII=', + 'base64' +) + +const createForm = (filename = '', content = 'data', contentType = 'image/png') => { + const form = new FormData() + const fileBuffer = Buffer.isBuffer(content) ? content : Buffer.from(content) + form.append('fileUpload1', fileBuffer, { filename, contentType }) + return form +} + +const url = constants.routes.ADD_A_PHOTO +const header = 'Add a photo' + +describe(url, () => { + beforeEach(() => { + BlobServiceClient.mockImplementation(() => ({ + getContainerClient: () => ({ + createIfNotExists: () => Promise.resolve(), + getBlockBlobClient: () => ({ + uploadData: () => Promise.resolve(), + downloadToBuffer: () => Promise.resolve(mockValidPng) + }) + }) + })) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('GET', () => { + it('should return correct view', async () => { + const response = await submitGetRequest({ url }, header) + expect(response.result).toContain(header) + }) + + it('should set upload-id if not present', async () => { + const response = await submitGetRequest({ url }, header) + expect(response.request.yar.get('upload-id')).toBeDefined() + }) + + it('should keep existing upload-id if already present', async () => { + const existingUploadId = 'existing-upload-id' + const response = await submitGetRequest({ url }, header, 200, { 'upload-id': existingUploadId }) + + expect(response.request.yar.get('upload-id')).toBe(existingUploadId) + }) + }) + + describe('POST', () => { + describe('empty file', () => { + it('should return correct error message if no file provided', async () => { + const form = new FormData() + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 200) + + expect(response.result).toContain('Select a file.') + }) + + it('should return correct error message if file missing original filename', async () => { + const form = createForm('') + form.append('fileUpload1', Buffer.from('data'), { filename: '' }) + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 200) + + expect(response.result).toContain('Select a file.') + }) + }) + + it('should return size error if file is over 10MB', async () => { + const form = createForm('big.png', Buffer.alloc(11 * 1024 * 1024), 'image/png') + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 200) + + expect(response.result).toContain('The selected file must be smaller than 10MB.') + }) + + it('should show max selected files content when 5 files already exist', async () => { + const form = createForm('valid.png', mockValidPng, 'image/png') + const thumbnails = Array.from({ length: 5 }, (_, index) => ({ + finalFilename: `upload-id/${index}.png`, + thumbLoc: `/public/thumbnails/upload-id-${index}.png` + })) + + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 200, { thumbnails }) + + expect(response.result).toContain('You have added the maximum number of photos allowed') + }) + + describe('upload failure', () => { + it('should return default error if upload fails', async () => { + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw new Error('fail') + }) + + const form = createForm('valid.png', mockValidPng, 'image/png') + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 200) + + expect(response.result).toContain('could not be uploaded') + }) + }) + + describe('successful upload', () => { + beforeEach(() => { + jest.spyOn(addPhoto, 'streamToBuffer').mockResolvedValue(mockValidPng) + }) + + it('should redirect on success', async () => { + const form = createForm('valid.png', mockValidPng, 'image/png') + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 302) + + expect(response.headers.location).toBe(constants.routes.YOUR_PHOTOS) + }) + + it('should store thumbnails in session', async () => { + const form = createForm('valid.png', mockValidPng, 'image/png') + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 302) + + const thumbnails = response.request.yar.get('thumbnails') + + expect(Array.isArray(thumbnails)).toBe(true) + }) + + it('should add at least one thumbnail to session on successful upload', async () => { + const form = createForm('valid.png', mockValidPng, 'image/png') + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 302) + + const thumbnails = response.request.yar.get('thumbnails') + + expect(thumbnails.length).toBeGreaterThan(0) + }) + + it('should store thumbLoc in session thumbnail entry', async () => { + const form = createForm('valid.png', mockValidPng, 'image/png') + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 302) + + const thumbnails = response.request.yar.get('thumbnails') + + expect(thumbnails[0]).toHaveProperty('thumbLoc') + }) + + it('should store finalFilename in session thumbnail entry', async () => { + const form = createForm('valid.png', mockValidPng, 'image/png') + const response = await submitPostRequest({ + url, + payload: form.getBuffer(), + headers: form.getHeaders() + }, 302) + + const thumbnails = response.request.yar.get('thumbnails') + + expect(thumbnails[0]).toHaveProperty('finalFilename') + }) + }) + }) +}) diff --git a/server/routes/__tests__/terms-for-uploading-photos.spec.js b/server/routes/__tests__/terms-for-uploading-photos.spec.js new file mode 100644 index 00000000..ab7d85cb --- /dev/null +++ b/server/routes/__tests__/terms-for-uploading-photos.spec.js @@ -0,0 +1,14 @@ +import { submitGetRequest } from '../../__test-helpers__/server.js' +import constants from '../../utils/constants.js' + +const url = constants.routes.TERMS_FOR_UPLOADING_PHOTOS +const header = 'Terms for uploading photos' + +describe(url, () => { + describe('GET', () => { + it(`Should return success response and correct view for ${url}`, async () => { + const response = await submitGetRequest({ url }, header, constants.statusCodes.OK) + expect(response.payload).toContain('Terms for uploading photos') + }) + }) +}) diff --git a/server/routes/__tests__/upload-photo.spec.js b/server/routes/__tests__/upload-photo.spec.js index f6dcacf9..b2a94ba3 100644 --- a/server/routes/__tests__/upload-photo.spec.js +++ b/server/routes/__tests__/upload-photo.spec.js @@ -1,4 +1,4 @@ -import { submitGetRequest } from '../../__test-helpers__/server.js' +import { submitGetRequest, submitPostRequest } from '../../__test-helpers__/server.js' import constants from '../../utils/constants.js' const url = constants.routes.UPLOAD_PHOTO @@ -11,4 +11,15 @@ describe(url, () => { expect(response.payload).toContain('Upload photos') }) }) + describe('POST', () => { + it(`Should return redirect response for ${url}`, async () => { + const response = await submitPostRequest({ url }, constants.statusCodes.REDIRECT) + expect(response.statusCode).toBe(constants.statusCodes.REDIRECT) + }) + + it(`Should redirect to ${constants.routes.ADD_A_PHOTO} for ${url}`, async () => { + const response = await submitPostRequest({ url }, constants.statusCodes.REDIRECT) + expect(response.headers.location).toBe(constants.routes.ADD_A_PHOTO) + }) + }) }) diff --git a/server/routes/__tests__/your-photos.spec.js b/server/routes/__tests__/your-photos.spec.js new file mode 100644 index 00000000..1a784175 --- /dev/null +++ b/server/routes/__tests__/your-photos.spec.js @@ -0,0 +1,14 @@ +import { submitGetRequest } from '../../__test-helpers__/server.js' +import constants from '../../utils/constants.js' + +const url = constants.routes.YOUR_PHOTOS +const header = 'Your photos' + +describe(url, () => { + describe('GET', () => { + it(`Should return success response and correct view for ${url}`, async () => { + const response = await submitGetRequest({ url }, header, constants.statusCodes.OK) + expect(response.payload).toContain('Your photos') + }) + }) +}) diff --git a/server/routes/add-a-photo.js b/server/routes/add-a-photo.js new file mode 100644 index 00000000..7860c5f1 --- /dev/null +++ b/server/routes/add-a-photo.js @@ -0,0 +1,192 @@ +import constants from '../utils/constants.js' +import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob' +import sharp from 'sharp' +import fs from 'node:fs' +import path from 'node:path' +import dirname from '../../dirname.cjs' +import crypto from 'node:crypto' + +const MAX_SELECTED_FILES = 5 +const UPLOAD_MAX_BYTES = 10 * 1024 * 1024 +const PAYLOAD_MAX_BYTES = 12 * 1024 * 1024 +const containerName = 'sir-media-uploads' + +async function initContainerClient () { + if (!initContainerClient.cachedClient) { + const blobServiceClient = new BlobServiceClient( + process.env.AZURE_BLOB_SERVICE_URL, + new StorageSharedKeyCredential( + process.env.AZURE_STORAGE_ACCOUNT, + process.env.AZURE_STORAGE_ACCESS_KEY + ) + ) + + const containerClient = blobServiceClient.getContainerClient(containerName) + await containerClient.createIfNotExists() + initContainerClient.cachedClient = containerClient + } + + return initContainerClient.cachedClient +} + +export function streamToBuffer (stream) { + return new Promise((resolve, reject) => { + const chunks = [] + stream.on('data', chunk => chunks.push(chunk)) + stream.on('end', () => resolve(Buffer.concat(chunks))) + stream.on('error', reject) + }) +} + +async function createThumbnail (filename) { + try { + const containerClient = await initContainerClient() + const blobClient = containerClient.getBlockBlobClient(filename) + const imgBuf = await blobClient.downloadToBuffer() + const thumbnail = await sharp(imgBuf) + .resize({ width: 200 }) + .toBuffer() + const [folder, file] = filename.split('/') + const [name, ext] = file.split('.') + + const thumbName = `${name}-thumbnail.${ext}` + const thumbBlobClient = containerClient.getBlockBlobClient(`${folder}/${thumbName}`) + await thumbBlobClient.uploadData(thumbnail) + + const localUploadLocation = `${folder}-${thumbName}` + + const thumbDir = path.join(dirname, 'server/public/build/thumbnails') + if (!fs.existsSync(thumbDir)) { + fs.mkdirSync(thumbDir, { recursive: true }) + } + + fs.writeFileSync( + path.join(thumbDir, localUploadLocation), + thumbnail + ) + + return localUploadLocation + } catch (err) { + const newErr = new Error('Unexpected upload failure', { cause: err }) + newErr.code = 'UPLOAD_FAILED' + throw newErr + } +} + +async function handleFileUpload (request, uploadId) { + const file = request.payload.fileUpload1 + + if (!file) { + const err = new Error('No file provided') + err.code = 'NO_FILE' + throw err + } + + if (!file.hapi?.filename) { + const err = new Error('Missing original filename') + err.code = 'NO_FILE' + throw err + } + + const fileBuffer = await streamToBuffer(file) + if (fileBuffer.length > UPLOAD_MAX_BYTES) { + const err = new Error('File too large') + err.code = 'FILE_TOO_LARGE' + throw err + } + + try { + await sharp(fileBuffer).metadata() + } catch { + // CONVERT IMAGE TYPE REMOVE ERROR + } + + const finalFilename = `${uploadId}/${file.hapi.filename}` + const containerClient = await initContainerClient() + + await containerClient + .getBlockBlobClient(finalFilename) + .uploadData(fileBuffer) + + return finalFilename +} + +const handlers = { + get: (request, h) => { + if (!request.yar.get('upload-id')) { + request.yar.set('upload-id', crypto.randomUUID()) + } + + return h.view(constants.views.ADD_A_PHOTO, { + maxSelectedFiles: false + }) + }, + + post: async (request, h) => { + const uploadId = request.yar.get('upload-id') + const thumbnails = request.yar.get('thumbnails') || [] + + if (thumbnails.length >= MAX_SELECTED_FILES) { + return h.view(constants.views.ADD_A_PHOTO, { + maxSelectedFiles: true + }) + } + + try { + const finalFilename = await handleFileUpload(request, uploadId) + const fileLoc = await createThumbnail(finalFilename) + + const thumbLoc = `/public/thumbnails/${fileLoc}` + thumbnails.push({ finalFilename, thumbLoc }) + + request.yar.set('thumbnails', thumbnails) + + return h.redirect(constants.routes.YOUR_PHOTOS) + } catch (err) { + console.log('Upload error:', err) + switch (err.code) { + case 'NO_FILE': + return h.view(constants.views.ADD_A_PHOTO, { + maxSelectedFiles: false, + errorMessage: 'Select a file.' + }) + + case 'FILE_TOO_LARGE': + return h.view(constants.views.ADD_A_PHOTO, { + maxSelectedFiles: false, + errorMessage: 'The selected file must be smaller than 10MB.' + }) + + default: + return h.view(constants.views.ADD_A_PHOTO, { + maxSelectedFiles: false, + errorMessage: 'The selected file could not be uploaded – try again.' + }) + } + } + } +} + +export default [ + { + method: 'GET', + path: constants.routes.ADD_A_PHOTO, + handler: handlers.get, + options: { auth: false } + }, + { + method: 'POST', + path: constants.routes.ADD_A_PHOTO, + handler: handlers.post, + options: { + auth: false, + payload: { + maxBytes: PAYLOAD_MAX_BYTES, + output: 'stream', + parse: true, + multipart: true, + allow: 'multipart/form-data' + } + } + } +] diff --git a/server/routes/terms-for-uploading-photos.js b/server/routes/terms-for-uploading-photos.js new file mode 100644 index 00000000..91372a37 --- /dev/null +++ b/server/routes/terms-for-uploading-photos.js @@ -0,0 +1,18 @@ +import constants from '../utils/constants.js' + +const handlers = { + get: (_request, h) => { + return h.view(constants.views.TERMS_FOR_UPLOADING_PHOTOS) + } +} + +export default [ + { + method: 'GET', + path: constants.routes.TERMS_FOR_UPLOADING_PHOTOS, + handler: handlers.get, + options: { + auth: false + } + } +] diff --git a/server/routes/upload-photo.js b/server/routes/upload-photo.js index 36e2f8b1..c7dbc3e5 100644 --- a/server/routes/upload-photo.js +++ b/server/routes/upload-photo.js @@ -3,13 +3,25 @@ import { returnFormattedDate } from '../utils/date-helpers.js' // TODO : variable 'Journey' should be done once the upload photo initial screen is designed const handlers = { - get: async (_request, h) => h.view(constants.views.UPLOAD_PHOTO, { journey: 'water pollution', dateTime: returnFormattedDate() }) + get: async (_request, h) => h.view(constants.views.UPLOAD_PHOTO, { journey: 'water pollution', dateTime: returnFormattedDate() }), + post: async (_request, h) => h.redirect(constants.routes.ADD_A_PHOTO) } export default [ { method: 'GET', path: constants.routes.UPLOAD_PHOTO, - handler: handlers.get + handler: handlers.get, + options: { + auth: false + } + }, + { + method: 'POST', + path: constants.routes.UPLOAD_PHOTO, + handler: handlers.post, + options: { + auth: false + } } ] diff --git a/server/routes/your-photos.js b/server/routes/your-photos.js new file mode 100644 index 00000000..37d56fca --- /dev/null +++ b/server/routes/your-photos.js @@ -0,0 +1,19 @@ +import constants from '../utils/constants.js' + +const handlers = { + get: (request, h) => { + const thumbnails = request.yar.get('thumbnails') + return h.view(constants.views.YOUR_PHOTOS, { thumbnails }) + } +} + +export default [ + { + method: 'GET', + path: constants.routes.YOUR_PHOTOS, + handler: handlers.get, + options: { + auth: false + } + } +] diff --git a/server/utils/constants.js b/server/utils/constants.js index 21b9c4e3..658a28af 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -36,6 +36,9 @@ const FEEDBACK_SUCCESS = 'feedback-success' // Upload Photos const UPLOAD_PHOTO = 'upload-photo' +const ADD_A_PHOTO = 'add-a-photo' +const YOUR_PHOTOS = 'your-photos' +const TERMS_FOR_UPLOADING_PHOTOS = 'terms-for-uploading-photos' const WATER_POLLUTION_START = 'water-pollution-start' const WATER_POLLUTION = 'water-pollution' @@ -180,6 +183,9 @@ const views = { FEEDBACK_SUCCESS, REPORT_SENT, UPLOAD_PHOTO, + ADD_A_PHOTO, + YOUR_PHOTOS, + TERMS_FOR_UPLOADING_PHOTOS, WATER_POLLUTION, WATER_POLLUTION_WATER_FEATURE, WATER_POLLUTION_LOCATION_OPTION, diff --git a/server/views/add-a-photo.html b/server/views/add-a-photo.html new file mode 100644 index 00000000..88b3d789 --- /dev/null +++ b/server/views/add-a-photo.html @@ -0,0 +1,65 @@ +{% extends 'layout.html' %} +{% from "dist/govuk/components/file-upload/macro.njk" import govukFileUpload %} + +{% set pageTitle = 'Add a photo' %} + +{% block content %} + +{% if errorMessage %} + +{% endif %} + +
+
+
+

{{ pageTitle }}

+ {% if maxSelectedFiles %} +

You have added the maximum number of photos allowed

+

See your photos

+ {% else %} +

Add your photos one at a time.

+

The photos must:

+ + + {% if errorMessage %} + {{ govukFileUpload({ + id: "file-upload-1", + name: "fileUpload1", + label: { + text: "Upload a photo" + }, + errorMessage: { text: errorMessage }, + javascript: true + }) }} + {% else %} + {{ govukFileUpload({ + id: "file-upload-1", + name: "fileUpload1", + label: { + text: "Upload a photo" + }, + javascript: true + }) }} + {% endif %} + + {{ govukButton({ + text: "Save and continue" + }) }} + {% endif %} +
+
+
+ +{% endblock %} + diff --git a/server/views/terms-for-uploading-photos.html b/server/views/terms-for-uploading-photos.html new file mode 100644 index 00000000..10b811b7 --- /dev/null +++ b/server/views/terms-for-uploading-photos.html @@ -0,0 +1,13 @@ +{% extends 'layout.html' %} + +{% set pageTitle = 'Terms for uploading photos' %} + +{% block content %} + +
+
+

{{ pageTitle }}

+
+
+ +{% endblock %} diff --git a/server/views/upload-photo.html b/server/views/upload-photo.html index f101473f..dfc5dcb4 100644 --- a/server/views/upload-photo.html +++ b/server/views/upload-photo.html @@ -1,8 +1,8 @@ -{% extends 'layout.html' %} +{% extends 'form-layout.html' %} {% set pageTitle = 'Upload photos' %} -{% block content %} +{% block formContent %}
diff --git a/server/views/your-photos.html b/server/views/your-photos.html new file mode 100644 index 00000000..7ab97871 --- /dev/null +++ b/server/views/your-photos.html @@ -0,0 +1,25 @@ +{% extends 'layout.html' %} + +{% set pageTitle = 'Your photos' %} + +{% block content %} + +
+
+

{{ pageTitle }}

+
+
+ +
+
+
    + {% for photo in thumbnails %} +
  • + Uploaded thumbnail +
  • + {% endfor %} +
+
+
+ +{% endblock %}