diff --git a/api/package-lock.json b/api/package-lock.json index c1c4ea0..8bd83d2 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -10,11 +10,15 @@ "dependencies": { "@azure/data-tables": "^13.3.2", "@azure/functions": "^4.16.2", - "@azure/storage-blob": "^12.33.0" + "@azure/storage-blob": "^12.33.0", + "sharp": "^0.34.5" }, "devDependencies": { "azure-functions-core-tools": "^4", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" } }, "node_modules/@azure/abort-controller": { @@ -261,6 +265,529 @@ "node": ">=20.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "musl" + ], + "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" + ], + "libc": [ + "musl" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "glibc" + ], + "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" + ], + "libc": [ + "musl" + ], + "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" + ], + "libc": [ + "musl" + ], + "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/@nodable/entities": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", @@ -762,6 +1289,15 @@ } } }, + "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/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -869,6 +1405,62 @@ "node": ">=14.0.0" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/strnum": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", diff --git a/api/package.json b/api/package.json index 59fbccf..3d9f1ed 100644 --- a/api/package.json +++ b/api/package.json @@ -16,7 +16,8 @@ "dependencies": { "@azure/data-tables": "^13.3.2", "@azure/functions": "^4.16.2", - "@azure/storage-blob": "^12.33.0" + "@azure/storage-blob": "^12.33.0", + "sharp": "^0.34.5" }, "devDependencies": { "azure-functions-core-tools": "^4", diff --git a/api/src/functions/media.ts b/api/src/functions/media.ts index 7779cc5..b5d02d2 100644 --- a/api/src/functions/media.ts +++ b/api/src/functions/media.ts @@ -4,11 +4,17 @@ import { getMediaTable, mediaPartitionKey, type MediaEntity } from '../mediaTabl const DEFAULT_LIMIT = 100; const MAX_LIMIT = 500; +// Upper bound on how many rows we'll ever pull from the partition to sort +// in memory (see below) - just a sanity ceiling so a single trip's archive +// growing far beyond "hundreds" of posts can't make this scan unbounded. +const SCAN_CAP = 5000; + export interface MediaPost { id: string; mediaType: MediaEntity['mediaType']; blobUrl: string; thumbUrl?: string; + displayUrl?: string; lat?: number; lon?: number; capturedAt: string; @@ -29,10 +35,14 @@ export async function media(request: HttpRequest, context: InvocationContext): P try { const table = await getMediaTable(); // RowKeys are generated with an inverted-timestamp prefix (see - // generateMediaRowKey in mediaTable.ts), so Table Storage already - // returns them newest-first within the partition. Stop as soon as we - // have `limit` posts instead of listing the whole partition and - // sorting in memory - keeps this cheap as uploads pile up. + // generateMediaRowKey in mediaTable.ts), so Table Storage returns them + // *upload*-time-newest-first within the partition - but that's not + // necessarily the same order as `capturedAt` (e.g. someone uploads an + // older photo later), and posts should read back in the order they + // were taken, not the order they were uploaded. So we list the whole + // partition (still cheap - a single trip's partition, bounded by + // SCAN_CAP) and sort by capturedAt ourselves instead of relying on + // RowKey order + stopping early. const entities = table.listEntities({ queryOptions: { filter: `PartitionKey eq '${mediaPartitionKey()}'` }, }); @@ -44,17 +54,23 @@ export async function media(request: HttpRequest, context: InvocationContext): P mediaType: entity.mediaType, blobUrl: entity.blobUrl, thumbUrl: entity.thumbUrl, + displayUrl: entity.displayUrl, lat: entity.lat, lon: entity.lon, capturedAt: entity.capturedAt, uploadedAt: entity.uploadedAt, }); - if (posts.length >= limit) break; + if (posts.length >= SCAN_CAP) break; } + // capturedAt is self-reported/client-clock (see mediaComplete.ts) but + // always falls back to a valid uploadedAt server timestamp when + // missing/unparseable, so this sort is always well-defined. + posts.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime()); + return { status: 200, - jsonBody: posts, + jsonBody: posts.slice(0, limit), headers: { 'Cache-Control': 'no-store' }, }; } catch (error) { diff --git a/api/src/functions/mediaDisplay.ts b/api/src/functions/mediaDisplay.ts new file mode 100644 index 0000000..55d88f1 --- /dev/null +++ b/api/src/functions/mediaDisplay.ts @@ -0,0 +1,131 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import sharp from 'sharp'; +import { getMediaTable, mediaPartitionKey, type MediaEntity } from '../mediaTable'; +import { getMediaContainer, generateDisplayBlobPath } from '../mediaBlob'; + +// Originals already at or below this size aren't worth recompressing - +// re-encoding a small JPEG a second time only costs quality for little to +// no byte savings, so we just point `displayUrl` at the original instead. +const SKIP_RECOMPRESSION_BELOW_BYTES = 600 * 1024; + +// Long-edge cap and JPEG quality for the generated "display" copy. Chosen +// to look essentially unchanged on a phone/laptop screen while cutting a +// typical 12MP+ camera photo down to a few hundred KB. +const MAX_EDGE = 1920; +const JPEG_QUALITY = 82; + +/** + * GET /api/media/{id}/display + * + * Lazily generates (once) and thereafter serves a cached, mid-size JPEG + * copy of a photo post, so opening it in the viewer doesn't require + * downloading the full-resolution original. The original blob is never + * modified - this only ever adds a second, smaller blob alongside it. + * + * First caller for a given post pays the one-time cost of a resize; every + * caller after that (including this same post being returned by /api/media + * once displayUrl is persisted) is a plain, free blob read. This also + * means posts uploaded *before* this endpoint existed get a display copy + * automatically the first time anyone opens them - no backfill needed. + * + * Public/anonymous, same exposure level as GET /api/media - this only + * reads/derives from data that's already publicly viewable via blobUrl. + * + * Note on data flow: this endpoint's HTTP response is always just a small + * JSON `{ displayUrl }` payload, never image bytes - the browser's + * tag then fetches those bytes directly from Blob Storage, same as + * blobUrl/thumbUrl. Only the one-time resize (below) reads the original + * and writes the resized copy through this Function's own memory - that's + * unavoidable since `sharp` needs the pixels to resize them - but it never + * touches the requesting client's connection either way. + */ +export async function mediaDisplay(request: HttpRequest, context: InvocationContext): Promise { + const id = request.params.id; + if (!id) return { status: 400, body: 'Missing id' }; + + const table = await getMediaTable(); + let entity: MediaEntity; + try { + entity = await table.getEntity(mediaPartitionKey(), id); + } catch { + return { status: 404, body: 'Post not found' }; + } + + // Already generated (or already aliased to the original) - fast path, + // no blob reads, no image processing. + if (entity.displayUrl) { + return { status: 200, jsonBody: { displayUrl: entity.displayUrl }, headers: { 'Cache-Control': 'no-store' } }; + } + + // Videos aren't resized here (see plan notes - re-encoding video is out + // of scope for now) - the frontend should just keep using blobUrl. + if (entity.mediaType !== 'photo') { + return { status: 200, jsonBody: { displayUrl: entity.blobUrl }, headers: { 'Cache-Control': 'no-store' } }; + } + + try { + const container = await getMediaContainer(); + const originalBlob = container.getBlockBlobClient(entity.blobPath); + + const properties = await originalBlob.getProperties(); + if ((properties.contentLength ?? Infinity) <= SKIP_RECOMPRESSION_BELOW_BYTES) { + await table.updateEntity>( + { partitionKey: entity.partitionKey, rowKey: entity.rowKey, displayUrl: entity.blobUrl }, + 'Merge', + ); + return { status: 200, jsonBody: { displayUrl: entity.blobUrl }, headers: { 'Cache-Control': 'no-store' } }; + } + + const original = await originalBlob.downloadToBuffer(); + // rotate() with no args applies the EXIF orientation tag (if any) then + // strips it, so the resized copy displays right-side-up everywhere, + // including viewers that ignore EXIF orientation. withoutEnlargement + // guards against upscaling smaller-than-MAX_EDGE originals. + const resized = await sharp(original) + .rotate() + .resize({ width: MAX_EDGE, height: MAX_EDGE, fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: JPEG_QUALITY }) + .toBuffer(); + + const displayBlobPath = generateDisplayBlobPath(); + const displayBlob = container.getBlockBlobClient(displayBlobPath); + await displayBlob.upload(resized, resized.length, { + blobHTTPHeaders: { blobContentType: 'image/jpeg' }, + }); + + const displayUrl = displayBlob.url; + await table.updateEntity>( + { partitionKey: entity.partitionKey, rowKey: entity.rowKey, displayBlobPath, displayUrl }, + 'Merge', + ); + + return { status: 200, jsonBody: { displayUrl }, headers: { 'Cache-Control': 'no-store' } }; + } catch (error) { + // Best-effort, same philosophy as client-side thumbnail generation - + // e.g. a HEIC original that this sharp build can't decode. Never fail + // the view over it, just fall back to the full-size original. + context.warn('Falling back to blobUrl - display generation failed for ' + id, error); + + // Persist the fallback too (aliasing displayUrl to blobUrl), same as + // the "already small enough" path above. Without this, a permanently + // unsupported original (e.g. HEIC) would retry the expensive resize - + // and lose to a full sharp() decode attempt - on every single view. + try { + await table.updateEntity>( + { partitionKey: entity.partitionKey, rowKey: entity.rowKey, displayUrl: entity.blobUrl }, + 'Merge', + ); + } catch (persistError) { + context.warn('Failed to persist blobUrl fallback for ' + id, persistError); + } + + return { status: 200, jsonBody: { displayUrl: entity.blobUrl }, headers: { 'Cache-Control': 'no-store' } }; + } +} + +app.http('mediaDisplay', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'media/{id}/display', + handler: mediaDisplay, +}); diff --git a/api/src/functions/mediaItem.ts b/api/src/functions/mediaItem.ts index 30b309e..4f5905c 100644 --- a/api/src/functions/mediaItem.ts +++ b/api/src/functions/mediaItem.ts @@ -71,6 +71,9 @@ export async function mediaItemDelete(request: HttpRequest, context: InvocationC if (entity.thumbBlobPath) { await container.getBlockBlobClient(entity.thumbBlobPath).deleteIfExists(); } + if (entity.displayBlobPath) { + await container.getBlockBlobClient(entity.displayBlobPath).deleteIfExists(); + } await table.deleteEntity(mediaPartitionKey(), id); return { status: 204 }; } catch (error) { diff --git a/api/src/functions/mediaMine.ts b/api/src/functions/mediaMine.ts index 591c5d9..0f2d03e 100644 --- a/api/src/functions/mediaMine.ts +++ b/api/src/functions/mediaMine.ts @@ -5,6 +5,11 @@ import type { MediaPost } from './media'; const MAX_LIMIT = 500; +// Upper bound on how many rows we'll ever pull for a single uploader to +// sort in memory - see media.ts for the same reasoning. Just a sanity +// ceiling, well above what any real uploader is expected to hit. +const SCAN_CAP = 5000; + /** * Lists a single uploader's own posts, keyed by their self-reported * email. Anonymous/public read, same exposure level as `/api/media` - @@ -33,17 +38,24 @@ export async function mediaMine(request: HttpRequest, context: InvocationContext mediaType: entity.mediaType, blobUrl: entity.blobUrl, thumbUrl: entity.thumbUrl, + displayUrl: entity.displayUrl, lat: entity.lat, lon: entity.lon, capturedAt: entity.capturedAt, uploadedAt: entity.uploadedAt, }); - if (posts.length >= MAX_LIMIT) break; + if (posts.length >= SCAN_CAP) break; } + // Sort by capturedAt (not RowKey/upload order - see media.ts) *before* + // truncating to MAX_LIMIT, so an uploader with more posts than + // MAX_LIMIT still gets their genuinely most-recently-captured posts, + // not whichever happened to be scanned first. + posts.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime()); + return { status: 200, - jsonBody: posts, + jsonBody: posts.slice(0, MAX_LIMIT), headers: { 'Cache-Control': 'no-store' }, }; } catch (error) { diff --git a/api/src/mediaBlob.ts b/api/src/mediaBlob.ts index 7cb2f60..9fe2236 100644 --- a/api/src/mediaBlob.ts +++ b/api/src/mediaBlob.ts @@ -64,6 +64,19 @@ export function generateThumbBlobPath(): string { return `${getCurrentTripId()}/${day}/thumb-${id}.jpg`; } +/** + * "Display" blobs are the mid-size viewer copy generated lazily, + * server-side, on first view (see mediaDisplay.ts) - unlike the thumbnail + * they're not created up-front at upload time, but the path shape + * (always a JPEG, same day-bucketing) mirrors generateThumbBlobPath() for + * consistency. + */ +export function generateDisplayBlobPath(): string { + const day = new Date().toISOString().slice(0, 10); + const id = crypto.randomUUID(); + return `${getCurrentTripId()}/${day}/display-${id}.jpg`; +} + /** * Issues a short-lived, single-blob, write-only SAS URL. The caller (a * phone in the field) PUTs the file bytes directly to Blob Storage using diff --git a/api/src/mediaTable.ts b/api/src/mediaTable.ts index 4c611d2..1cc1856 100644 --- a/api/src/mediaTable.ts +++ b/api/src/mediaTable.ts @@ -59,6 +59,14 @@ export interface MediaEntity { // in some browsers) - falls back to blobUrl on the frontend when absent. thumbBlobPath?: string; thumbUrl?: string; + // Mid-size JPEG generated lazily, server-side, the first time anyone + // opens the post in the full-size viewer (see mediaDisplay.ts). Keeps + // the original full-resolution upload untouched in storage while + // avoiding a multi-MB download every time someone just wants to *look* + // at the photo. Optional/lazy for the same reasons as thumbBlobPath - + // frontend falls back to blobUrl until/unless this exists. + displayBlobPath?: string; + displayUrl?: string; } export function mediaPartitionKey(): string { diff --git a/src/components/MediaLightbox.tsx b/src/components/MediaLightbox.tsx index 35f6b93..80ac0f4 100644 --- a/src/components/MediaLightbox.tsx +++ b/src/components/MediaLightbox.tsx @@ -31,6 +31,36 @@ export default function MediaLightbox({ const post = posts[safeIndex]; const canNavigate = posts.length > 1; + // The full-resolution original (post.blobUrl) can be several MB - bad on + // a metered connection just to *look* at a photo. If the post doesn't + // already carry a generated display-size copy (see mediaDisplay.ts), + // request one on open (and again on every navigation, per-post); the + // server generates (and caches) it lazily on first request, so this is a + // one-time cost per post, not per view. Shown immediately with the + // full-size blobUrl as a fallback while that request is in flight (or if + // it fails) so opening/navigating never stalls. + const [displayUrl, setDisplayUrl] = useState(post?.displayUrl); + + useEffect(() => { + setDisplayUrl(post?.displayUrl); + if (!post || post.mediaType !== 'photo' || post.displayUrl) return; + + let cancelled = false; + fetch(`/api/media/${post.id}/display`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { displayUrl?: string } | null) => { + if (!cancelled && data?.displayUrl) setDisplayUrl(data.displayUrl); + }) + .catch(() => { + // Network hiccup or the endpoint failed - blobUrl fallback below + // already covers this, nothing else to do. + }); + + return () => { + cancelled = true; + }; + }, [post?.id, post?.mediaType, post?.displayUrl]); + function goPrev() { setIndex((i) => (Math.min(i, posts.length - 1) - 1 + posts.length) % posts.length); } @@ -66,6 +96,21 @@ export default function MediaLightbox({ aria-modal="true" aria-label={post.mediaType === 'video' ? 'Trip video viewer' : 'Trip photo viewer'} > + {/* Always the full-resolution original (post.blobUrl), never + displayUrl/thumbUrl - the viewer's compressed copy is meant to + save bandwidth for casual viewing, not to be what someone + actually keeps. */} + e.stopPropagation()} + aria-label={post.mediaType === 'video' ? 'Download full-size video' : 'Download full-size photo'} + className="absolute top-4 right-[68px] z-10 w-11 h-11 flex items-center justify-center rounded-full bg-black/70 text-white ring-1 ring-white/30 hover:bg-black/90 transition-colors" + > + + - e.stopPropagation()} - aria-label="Download original" - className="absolute top-4 right-[68px] z-10 w-11 h-11 flex items-center justify-center rounded-full bg-black/70 text-white ring-1 ring-white/30 hover:bg-black/90 transition-colors" - > - - - {canNavigate && ( diff --git a/src/hooks/useMediaPosts.ts b/src/hooks/useMediaPosts.ts index c88da7b..e7e4a27 100644 --- a/src/hooks/useMediaPosts.ts +++ b/src/hooks/useMediaPosts.ts @@ -5,6 +5,7 @@ export interface MediaPost { mediaType: 'photo' | 'video'; blobUrl: string; thumbUrl?: string; + displayUrl?: string; lat?: number; lon?: number; capturedAt: string; @@ -54,10 +55,10 @@ export function useMediaPosts(): MediaPost[] { const res = await fetch('/api/media', { cache: 'no-store' }); if (!res.ok) return; const data: MediaPost[] = await res.json(); - // The API returns newest-*uploaded*-first (cheap Table Storage - // pagination via an inverted-timestamp rowKey) - re-sort here by - // when the photo was actually taken so every consumer of this - // hook gets trip-timeline order "for free". + // The API (media.ts) already sorts by capturedAt server-side, but + // re-sort here too - cheap for "hundreds" of posts, and keeps + // every consumer of this hook guaranteed trip-timeline order even + // if that ever changes API-side. if (isMounted.current) setPosts(sortByCapturedAtDesc(data)); } catch { // Network hiccup or offline - keep showing the last known posts