From 96e771bbfbe6bf1d83a47b89c491ad56583fa246 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Wed, 25 Mar 2026 17:32:44 +0400 Subject: [PATCH] added direct s3 bucket calls from teh client --- backend/package.json | 3 +- backend/src/routes/file.js | 27 + backend/src/services/file.js | 40 ++ backend/yarn.lock | 461 ++++++++++-------- .../src/components/RuntimePresentation.tsx | 120 ++++- frontend/src/hooks/usePreloadOrchestrator.ts | 217 +++++++-- frontend/src/hooks/useTransitionPlayback.ts | 72 ++- frontend/src/lib/assetUrl.ts | 300 +++++++++++- frontend/src/pages/_app.tsx | 19 +- 9 files changed, 978 insertions(+), 281 deletions(-) diff --git a/backend/package.json b/backend/package.json index d9cb074..00b4988 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1011.0", + "@aws-sdk/s3-request-presigner": "^3.1016.0", "@google-cloud/storage": "^7.0.0", "axios": "^1.13.0", "bcrypt": "^6.0.0", @@ -41,9 +42,9 @@ "passport-jwt": "^4.0.1", "passport-microsoft": "^2.0.0", "pg": "^8.20.0", + "pg-hstore": "2.3.4", "pino": "^9.0.0", "pino-pretty": "^11.0.0", - "pg-hstore": "2.3.4", "sequelize": "^6.37.0", "sequelize-json-schema": "^2.1.1", "sqlite": "4.0.15", diff --git a/backend/src/routes/file.js b/backend/src/routes/file.js index cb70536..ec91434 100644 --- a/backend/src/routes/file.js +++ b/backend/src/routes/file.js @@ -18,6 +18,33 @@ router.get('/download', (req, res) => { services.downloadFile(req, res); }); +// POST /api/file/presign - Generate presigned URLs for multiple assets +router.post('/presign', jsonParser, async (req, res) => { + const { urls } = req.body || {}; + + if (!Array.isArray(urls) || urls.length === 0) { + return res.status(400).json({ error: 'urls array required' }); + } + + if (urls.length > 50) { + return res.status(400).json({ error: 'Maximum 50 URLs per request' }); + } + + // Validate that all URLs are strings + const invalidUrls = urls.filter((url) => typeof url !== 'string' || !url.trim()); + if (invalidUrls.length > 0) { + return res.status(400).json({ error: 'All URLs must be non-empty strings' }); + } + + try { + const presignedUrls = await services.generatePresignedUrls(urls); + res.json({ presignedUrls }); + } catch (error) { + console.error('Failed to generate presigned URLs', error); + res.status(500).json({ error: 'Failed to generate presigned URLs' }); + } +}); + router.post('/upload/:table/:field', passport.authenticate('jwt', {session: false}), (req, res) => { const fileName = `${req.params.table}/${req.params.field}`; diff --git a/backend/src/services/file.js b/backend/src/services/file.js index cfe29b7..9de1388 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -13,6 +13,7 @@ const { ListObjectsV2Command, DeleteObjectsCommand, } = require('@aws-sdk/client-s3'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const ensureDirectoryExistence = (filePath) => { const dirname = path.dirname(filePath); @@ -1090,6 +1091,44 @@ const deleteFile = async (privateUrl) => { return deleteLocal(privateUrl); } +const PRESIGN_EXPIRY_SECONDS = 3600; // 1 hour + +/** + * Generate presigned GET URLs for multiple assets. + * For S3: returns direct S3 signed URLs. + * For other providers: returns backend proxy URLs. + * + * @param {string[]} urls - Array of storage_key paths + * @returns {Promise>} Map of original path to presigned/proxy URL + */ +const generatePresignedUrls = async (urls) => { + const provider = getFileStorageProvider(); + + if (provider !== 's3') { + // For non-S3 providers, return backend proxy URLs + return urls.reduce((acc, url) => { + acc[url] = `/api/file/download?privateUrl=${encodeURIComponent(url)}`; + return acc; + }, {}); + } + + const { client, bucket, prefix } = initS3(); + + const presignedUrls = {}; + + await Promise.all( + urls.map(async (url) => { + const key = buildStoragePath(prefix, url); + const command = new GetObjectCommand({ Bucket: bucket, Key: key }); + presignedUrls[url] = await getSignedUrl(client, command, { + expiresIn: PRESIGN_EXPIRY_SECONDS, + }); + }) + ); + + return presignedUrls; +}; + module.exports = { initUploadSession, getUploadSession, @@ -1110,4 +1149,5 @@ module.exports = { downloadGCloud, uploadS3, downloadS3, + generatePresignedUrls, } diff --git a/backend/yarn.lock b/backend/yarn.lock index f89bec4..c5d3a74 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -77,7 +77,7 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.6.2" -"@aws-crypto/sha256-js@^5.2.0", "@aws-crypto/sha256-js@5.2.0": +"@aws-crypto/sha256-js@5.2.0", "@aws-crypto/sha256-js@^5.2.0": version "5.2.0" resolved "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz" integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== @@ -93,7 +93,7 @@ dependencies: tslib "^2.6.2" -"@aws-crypto/util@^5.2.0", "@aws-crypto/util@5.2.0": +"@aws-crypto/util@5.2.0", "@aws-crypto/util@^5.2.0": version "5.2.0" resolved "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz" integrity sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ== @@ -182,6 +182,25 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" +"@aws-sdk/core@^3.973.24": + version "3.973.24" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.973.24.tgz#3fe12eb94fb8733a7b07f7a00fda9b20b42179fd" + integrity sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw== + dependencies: + "@aws-sdk/types" "^3.973.6" + "@aws-sdk/xml-builder" "^3.972.15" + "@smithy/core" "^3.23.12" + "@smithy/node-config-provider" "^4.3.12" + "@smithy/property-provider" "^4.2.12" + "@smithy/protocol-http" "^5.3.12" + "@smithy/signature-v4" "^5.3.12" + "@smithy/smithy-client" "^4.12.7" + "@smithy/types" "^4.13.1" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-middleware" "^4.2.12" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + "@aws-sdk/crc64-nvme@^3.972.5": version "3.972.5" resolved "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz" @@ -410,6 +429,26 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" +"@aws-sdk/middleware-sdk-s3@^3.972.24": + version "3.972.24" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.24.tgz#1a327a22ef88042b3a5542eb98fd31c31e568af9" + integrity sha512-4sXxVC/enYgMkZefNMOzU6C6KtAXEvwVJLgNcUx1dvROH6GvKB5Sm2RGnGzTp0/PwkibIyMw4kOzF8tbLfaBAQ== + dependencies: + "@aws-sdk/core" "^3.973.24" + "@aws-sdk/types" "^3.973.6" + "@aws-sdk/util-arn-parser" "^3.972.3" + "@smithy/core" "^3.23.12" + "@smithy/node-config-provider" "^4.3.12" + "@smithy/protocol-http" "^5.3.12" + "@smithy/signature-v4" "^5.3.12" + "@smithy/smithy-client" "^4.12.7" + "@smithy/types" "^4.13.1" + "@smithy/util-config-provider" "^4.2.2" + "@smithy/util-middleware" "^4.2.12" + "@smithy/util-stream" "^4.5.20" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + "@aws-sdk/middleware-ssec@^3.972.8": version "3.972.8" resolved "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz" @@ -488,6 +527,32 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/s3-request-presigner@^3.1016.0": + version "3.1016.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1016.0.tgz#4903e6188a8fdc16cd5f331ae02b9ea9843d881e" + integrity sha512-BROPno9Y8xYltQu5k1AupDPaWdFR9Ig8zfDSZzTE+MTvKpif6wyAHFJRW0C0xIwZckaHya2oFoTZbPHtyIlQkg== + dependencies: + "@aws-sdk/signature-v4-multi-region" "^3.996.12" + "@aws-sdk/types" "^3.973.6" + "@aws-sdk/util-format-url" "^3.972.8" + "@smithy/middleware-endpoint" "^4.4.27" + "@smithy/protocol-http" "^5.3.12" + "@smithy/smithy-client" "^4.12.7" + "@smithy/types" "^4.13.1" + tslib "^2.6.2" + +"@aws-sdk/signature-v4-multi-region@^3.996.12": + version "3.996.12" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.12.tgz#7228509cba3845e00c089fe41b4636cf81fee798" + integrity sha512-abRObSqjVeKUUHIZfAp78PTYrEsxCgVKDs/YET357pzT5C02eDDEvmWyeEC2wglWcYC4UTbBFk22gd2YJUlCQg== + dependencies: + "@aws-sdk/middleware-sdk-s3" "^3.972.24" + "@aws-sdk/types" "^3.973.6" + "@smithy/protocol-http" "^5.3.12" + "@smithy/signature-v4" "^5.3.12" + "@smithy/types" "^4.13.1" + tslib "^2.6.2" + "@aws-sdk/signature-v4-multi-region@^3.996.9": version "3.996.9" resolved "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.9.tgz" @@ -539,6 +604,16 @@ "@smithy/util-endpoints" "^3.3.3" tslib "^2.6.2" +"@aws-sdk/util-format-url@^3.972.8": + version "3.972.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-format-url/-/util-format-url-3.972.8.tgz#803273f72617edb16b4087bcff2e52d740a26250" + integrity sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A== + dependencies: + "@aws-sdk/types" "^3.973.6" + "@smithy/querystring-builder" "^4.2.12" + "@smithy/types" "^4.13.1" + tslib "^2.6.2" + "@aws-sdk/util-locate-window@^3.0.0": version "3.965.5" resolved "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz" @@ -577,6 +652,15 @@ fast-xml-parser "5.5.6" tslib "^2.6.2" +"@aws-sdk/xml-builder@^3.972.15": + version "3.972.15" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz#7cbc823f8eb11fa8c02d81a744892e41b1762619" + integrity sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA== + dependencies: + "@smithy/types" "^4.13.1" + fast-xml-parser "5.5.8" + tslib "^2.6.2" + "@aws/lambda-invoke-store@^0.2.2": version "0.2.4" resolved "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz" @@ -1121,6 +1205,20 @@ "@smithy/util-middleware" "^4.2.12" tslib "^2.6.2" +"@smithy/middleware-endpoint@^4.4.27": + version "4.4.27" + resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.27.tgz#cf2b334f7fc302e7ebf3fe00c1a1279ee9214afd" + integrity sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA== + dependencies: + "@smithy/core" "^3.23.12" + "@smithy/middleware-serde" "^4.2.15" + "@smithy/node-config-provider" "^4.3.12" + "@smithy/shared-ini-file-loader" "^4.4.7" + "@smithy/types" "^4.13.1" + "@smithy/url-parser" "^4.2.12" + "@smithy/util-middleware" "^4.2.12" + tslib "^2.6.2" + "@smithy/middleware-retry@^4.4.43": version "4.4.43" resolved "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.43.tgz" @@ -1250,6 +1348,19 @@ "@smithy/util-stream" "^4.5.20" tslib "^2.6.2" +"@smithy/smithy-client@^4.12.7": + version "4.12.7" + resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.12.7.tgz#3867272c062e39d3d4b719bf83ba491c76e1ee93" + integrity sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ== + dependencies: + "@smithy/core" "^3.23.12" + "@smithy/middleware-endpoint" "^4.4.27" + "@smithy/middleware-stack" "^4.2.12" + "@smithy/protocol-http" "^5.3.12" + "@smithy/types" "^4.13.1" + "@smithy/util-stream" "^4.5.20" + tslib "^2.6.2" + "@smithy/types@^4.13.1": version "4.13.1" resolved "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz" @@ -1518,13 +1629,10 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.9.0: - version "8.15.0" - -agent-base@^7.1.0, agent-base@^7.1.2: - version "7.1.4" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" - integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== +acorn@^8.9.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== agent-base@6: version "6.0.2" @@ -1533,8 +1641,15 @@ agent-base@6: dependencies: debug "4" +agent-base@^7.1.0, agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + ajv@^6.12.4: - version "6.12.6" + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -1813,14 +1928,7 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -brace-expansion@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" - integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== - dependencies: - balanced-match "^1.0.0" - -brace-expansion@^2.0.2: +brace-expansion@^2.0.1, brace-expansion@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== @@ -1936,22 +2044,7 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chokidar@^3.5.2: - version "3.6.0" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" - integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -chokidar@^3.5.3: +chokidar@^3.5.2, chokidar@^3.5.3: version "3.6.0" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== @@ -2006,6 +2099,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +commander@6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz" + integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== + commander@^10.0.0: version "10.0.1" resolved "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz" @@ -2016,11 +2114,6 @@ commander@^6.1.0: resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== -commander@6.2.0: - version "6.2.0" - resolved "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz" - integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== - concat-map@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" @@ -2044,7 +2137,7 @@ config-chain@^1.1.13: ini "^1.3.4" proto-list "~1.2.1" -content-disposition@^0.5.3, content-disposition@0.5.4: +content-disposition@0.5.4, content-disposition@^0.5.3: version "0.5.4" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz" integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== @@ -2154,28 +2247,6 @@ dateformat@^4.6.3: resolved "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz" integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== -debug@^3.2.7: - version "3.2.7" - dependencies: - ms "^2.1.1" - -debug@^4, debug@^4.3.4, debug@^4.3.5, debug@4: - version "4.3.5" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz" - integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== - dependencies: - ms "2.1.2" - -debug@^4.3.1: - version "4.4.3" - dependencies: - ms "^2.1.3" - -debug@^4.3.2: - version "4.4.3" - dependencies: - ms "^2.1.3" - debug@2.6.9: version "2.6.9" resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" @@ -2183,6 +2254,27 @@ debug@2.6.9: dependencies: ms "2.0.0" +debug@4, debug@^4, debug@^4.3.4, debug@^4.3.5: + version "4.3.5" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + +debug@^3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@^4.3.1, debug@^4.3.2: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + decamelize@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz" @@ -2226,16 +2318,16 @@ denque@^1.4.1: resolved "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz" integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== -depd@^1.1.0: - version "1.1.2" - resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz" - integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== - depd@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +depd@^1.1.0: + version "1.1.2" + resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + destroy@1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz" @@ -2246,18 +2338,20 @@ diff@^5.2.0: resolved "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz" integrity sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A== -doctrine@^2.1.0: - version "2.1.0" - dependencies: - esutils "^2.0.2" - -doctrine@^3.0.0, doctrine@3.0.0: +doctrine@3.0.0, doctrine@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== dependencies: esutils "^2.0.2" +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + dotenv@^16.4.0: version "16.6.1" resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz" @@ -2292,7 +2386,7 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ecdsa-sig-formatter@^1.0.11, ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -2583,7 +2677,7 @@ eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -"eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", eslint@^8.57.0: +eslint@^8.57.0: version "8.57.1" resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz" integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== @@ -2683,7 +2777,7 @@ express-validator@^7.0.0: lodash "^4.17.21" validator "~13.15.23" -"express@>=4.0.0 || >=5.0.0-beta", express@4.18.2: +express@4.18.2: version "4.18.2" resolved "https://registry.npmjs.org/express/-/express-4.18.2.tgz" integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== @@ -2757,7 +2851,7 @@ fast-xml-builder@^1.1.4: dependencies: path-expression-matcher "^1.1.3" -fast-xml-parser@^5.3.4, fast-xml-parser@5.5.6: +fast-xml-parser@5.5.6, fast-xml-parser@^5.3.4: version "5.5.6" resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz" integrity sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw== @@ -2766,6 +2860,15 @@ fast-xml-parser@^5.3.4, fast-xml-parser@5.5.6: path-expression-matcher "^1.1.3" strnum "^2.1.2" +fast-xml-parser@5.5.8: + version "5.5.8" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz#929571ed8c5eb96e6d9bd572ba14fc4b84875716" + integrity sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ== + dependencies: + fast-xml-builder "^1.1.4" + path-expression-matcher "^1.2.0" + strnum "^2.2.0" + fastq@^1.6.0: version "1.20.1" resolved "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz" @@ -2823,7 +2926,9 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== flatted@^3.2.9: - version "3.3.3" + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== follow-redirects@^1.15.11: version "1.15.11" @@ -2839,6 +2944,8 @@ for-each@^0.3.3: for-each@^0.3.5: version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== dependencies: is-callable "^1.2.7" @@ -2883,7 +2990,7 @@ forwarded@0.2.0: resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@^0.5.2, fresh@0.5.2: +fresh@0.5.2, fresh@^0.5.2: version "0.5.2" resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== @@ -2903,6 +3010,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -2972,29 +3084,7 @@ get-caller-file@^2.0.5: resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" - -get-intrinsic@^1.2.1, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" - -get-intrinsic@^1.2.3: +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== @@ -3049,6 +3139,8 @@ get-symbol-description@^1.1.0: glob-parent@^6.0.2: version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== dependencies: is-glob "^4.0.3" @@ -3059,6 +3151,18 @@ glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" +glob@7.1.6: + version "7.1.6" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^10.4.2: version "10.5.0" resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" @@ -3094,18 +3198,6 @@ glob@^8.1.0: minimatch "^5.0.1" once "^1.3.0" -glob@7.1.6: - version "7.1.6" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - globals@^13.19.0: version "13.24.0" resolved "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz" @@ -3290,20 +3382,6 @@ https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.1: agent-base "^7.1.2" debug "4" -iconv-lite@^0.6.2: - version "0.6.3" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - -iconv-lite@^0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" @@ -3311,6 +3389,13 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.6.2, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" @@ -3352,7 +3437,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.3, inherits@^2.0.4, inherits@2, inherits@2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3464,6 +3549,8 @@ is-core-module@^2.13.0: is-core-module@^2.16.1: version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== dependencies: hasown "^2.0.2" @@ -3647,7 +3734,7 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" -is-symbol@^1.0.4: +is-symbol@^1.0.4, is-symbol@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz" integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== @@ -3656,13 +3743,6 @@ is-symbol@^1.0.4: has-symbols "^1.1.0" safe-regex-test "^1.1.0" -is-symbol@^1.1.1: - version "1.1.1" - dependencies: - call-bound "^1.0.2" - has-symbols "^1.1.0" - safe-regex-test "^1.1.0" - is-typed-array@^1.1.13: version "1.1.13" resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz" @@ -3986,16 +4066,16 @@ media-typer@0.3.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== -merge-descriptors@^1.0.1: - version "1.0.3" - resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz" - integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== - merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz" integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== +merge-descriptors@^1.0.1: + version "1.0.3" + resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" @@ -4013,7 +4093,7 @@ mime-types@^2.1.12, mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: dependencies: mime-db "1.52.0" -mime@^1.3.4, mime@1.6.0: +mime@1.6.0, mime@^1.3.4: version "1.6.0" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -4044,14 +4124,7 @@ minimatch@^5.0.1, minimatch@^5.1.6: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.1: - version "9.0.9" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz" - integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== - dependencies: - brace-expansion "^2.0.2" - -minimatch@^9.0.4: +minimatch@^9.0.1, minimatch@^9.0.4: version "9.0.9" resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz" integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== @@ -4101,16 +4174,11 @@ moment-timezone@^0.5.43: dependencies: moment "^2.29.4" -moment@^2.29.4, moment@2.30.1: +moment@2.30.1, moment@^2.29.4: version "2.30.1" resolved "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz" integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== -ms@^2.1.1, ms@^2.1.3, ms@2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -4121,6 +4189,11 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + multer@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz" @@ -4337,11 +4410,6 @@ open@^8.0.0: is-docker "^2.1.1" is-wsl "^2.2.0" -openapi-types@>=7: - version "12.1.3" - resolved "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz" - integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== - optionator@^0.9.3: version "0.9.4" resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" @@ -4416,7 +4484,7 @@ passport-microsoft@^2.0.0: dependencies: passport-oauth2 "1.8.0" -passport-oauth2@^1.1.2, passport-oauth2@1.8.0: +passport-oauth2@1.8.0, passport-oauth2@^1.1.2: version "1.8.0" resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz" integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== @@ -4427,7 +4495,7 @@ passport-oauth2@^1.1.2, passport-oauth2@1.8.0: uid2 "0.0.x" utils-merge "1.x.x" -passport-strategy@^1.0.0, passport-strategy@1.x.x: +passport-strategy@1.x.x, passport-strategy@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== @@ -4451,6 +4519,11 @@ path-expression-matcher@^1.1.3: resolved "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz" integrity sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ== +path-expression-matcher@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz#9bdae3787f43b0857b0269e9caaa586c12c8abee" + integrity sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" @@ -4527,7 +4600,7 @@ pg-types@2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg@^8.20.0, pg@>=8.0: +pg@^8.20.0: version "8.20.0" resolved "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz" integrity sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA== @@ -4681,6 +4754,8 @@ pump@^3.0.0: punycode@^2.1.0: version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== qs@6.11.0: version "6.11.0" @@ -4826,6 +4901,8 @@ resolve@^1.22.1: resolve@^1.22.4: version "1.22.11" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== dependencies: is-core-module "^2.16.1" path-parse "^1.0.7" @@ -4890,7 +4967,7 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0, safe-buffer@5.2.1: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -4946,12 +5023,7 @@ semver@^6.3.1: resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.3: - version "7.7.4" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" - integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== - -semver@^7.5.4: +semver@^7.5.3, semver@^7.5.4: version "7.7.4" resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -5003,7 +5075,7 @@ sequelize-pool@^7.1.0: resolved "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz" integrity sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg== -sequelize@^6.37.0, "sequelize@>= 4": +sequelize@^6.37.0: version "6.37.8" resolved "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz" integrity sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw== @@ -5214,13 +5286,6 @@ streamsearch@^1.1.0: resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -string_decoder@^1.1.1, string_decoder@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" @@ -5299,6 +5364,13 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -5335,6 +5407,11 @@ strnum@^2.1.2: resolved "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz" integrity sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg== +strnum@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.2.tgz#f11fd94ab62b536ba2ecc615858f3747c2881b3f" + integrity sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA== + stubs@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz" @@ -5644,7 +5721,7 @@ universalify@^2.0.0: resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== -unpipe@~1.0.0, unpipe@1.0.0: +unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== @@ -5661,7 +5738,7 @@ util-deprecate@^1.0.1: resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -utils-merge@^1.0.1, utils-merge@1.0.1, utils-merge@1.x.x: +utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== @@ -5671,12 +5748,7 @@ uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^9.0.0: - version "9.0.1" - resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== - -uuid@^9.0.1: +uuid@^9.0.0, uuid@^9.0.1: version "9.0.1" resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== @@ -5755,18 +5827,7 @@ which-collection@^1.0.2: is-weakmap "^2.0.2" is-weakset "^2.0.3" -which-typed-array@^1.1.14: - version "1.1.15" - resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz" - integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-tostringtag "^1.0.2" - -which-typed-array@^1.1.15: +which-typed-array@^1.1.14, which-typed-array@^1.1.15: version "1.1.15" resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz" integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index 5331e4c..665c3a4 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -26,7 +26,12 @@ import { PRELOAD_CONFIG } from '../config/preload.config'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { logger } from '../lib/logger'; -import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; +import { + resolveAssetPlaybackUrl, + markPresignedUrlFailed, + isRelativeStoragePath, +} from '../lib/assetUrl'; +import { baseURLApi } from '../config'; import { buildElementStyle } from '../lib/elementStyles'; import type { RuntimeProject, @@ -42,20 +47,90 @@ interface RuntimePresentationProps { const getRows = (response: any) => Array.isArray(response?.data?.rows) ? response.data.rows : []; +/** + * Check if URL is a presigned S3 URL + */ +const isPresignedUrl = (url: string): boolean => { + return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature='); +}; + +/** + * Build proxy URL from storage key + */ +const buildProxyUrl = (storageKey: string): string => { + const normalizedPath = storageKey.replace(/^\/+/, ''); + return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`; +}; + +/** + * Load and decode a single image with presigned URL fallback + */ +const loadImageWithFallback = ( + url: string, + storageKey?: string, +): Promise => { + return new Promise((resolve) => { + const img = new window.Image(); + + const tryLoad = (srcUrl: string, isRetry = false) => { + img.src = srcUrl; + + const handleSuccess = () => { + if (typeof img.decode === 'function') { + img + .decode() + .then(() => resolve()) + .catch(() => resolve()); + } else { + resolve(); + } + }; + + const handleError = () => { + // If this was a presigned URL and we have a storage key, retry with proxy + if (!isRetry && isPresignedUrl(srcUrl) && storageKey) { + logger.info('Image presigned URL failed, retrying with proxy', { + storageKey: storageKey.slice(-50), + }); + markPresignedUrlFailed(storageKey); + const proxyUrl = buildProxyUrl(storageKey); + tryLoad(proxyUrl, true); + } else { + // Give up and resolve anyway to not block navigation + resolve(); + } + }; + + img.onload = handleSuccess; + img.onerror = handleError; + }; + + tryLoad(url); + }); +}; + /** * Wait for all images on a page to be decoded before switching. + * Handles presigned URL failures by retrying with proxy URLs. */ const waitForPageImages = async ( page: RuntimePage | null, - timeoutMs = 2000, + timeoutMs = 3000, ): Promise => { if (!page) return; - const imageUrls: string[] = []; + // Collect image URLs with their original storage keys for fallback + const imageEntries: Array<{ url: string; storageKey?: string }> = []; if (page.background_image_url) { - const url = resolveAssetPlaybackUrl(page.background_image_url); - if (url) imageUrls.push(url); + const storageKey = page.background_image_url; + const url = resolveAssetPlaybackUrl(storageKey); + if (url) { + imageEntries.push({ + url, + storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, + }); + } } try { @@ -77,7 +152,12 @@ const waitForPageImages = async ( const value = el[field]; if (typeof value === 'string' && value) { const url = resolveAssetPlaybackUrl(value); - if (url && !imageUrls.includes(url)) imageUrls.push(url); + if (url && !imageEntries.some((e) => e.url === url)) { + imageEntries.push({ + url, + storageKey: isRelativeStoragePath(value) ? value : undefined, + }); + } } }); @@ -88,7 +168,14 @@ const waitForPageImages = async ( items.forEach((item: Record) => { if (typeof item.imageUrl === 'string' && item.imageUrl) { const url = resolveAssetPlaybackUrl(item.imageUrl); - if (url && !imageUrls.includes(url)) imageUrls.push(url); + if (url && !imageEntries.some((e) => e.url === url)) { + imageEntries.push({ + url, + storageKey: isRelativeStoragePath(item.imageUrl) + ? item.imageUrl + : undefined, + }); + } } }); } @@ -98,23 +185,10 @@ const waitForPageImages = async ( // Ignore parse errors } - if (imageUrls.length === 0) return; + if (imageEntries.length === 0) return; - const decodePromises = imageUrls.map( - (url) => - new Promise((resolve) => { - const img = new window.Image(); - img.src = url; - if (typeof img.decode === 'function') { - img - .decode() - .then(() => resolve()) - .catch(() => resolve()); - } else { - img.onload = () => resolve(); - img.onerror = () => resolve(); - } - }), + const decodePromises = imageEntries.map((entry) => + loadImageWithFallback(entry.url, entry.storageKey), ); await Promise.race([ diff --git a/frontend/src/hooks/usePreloadOrchestrator.ts b/frontend/src/hooks/usePreloadOrchestrator.ts index 0a6ed5b..03cbfcf 100644 --- a/frontend/src/hooks/usePreloadOrchestrator.ts +++ b/frontend/src/hooks/usePreloadOrchestrator.ts @@ -12,8 +12,31 @@ import { downloadEventBus } from '../lib/offline/DownloadEventBus'; import { StorageManager } from '../lib/offline/StorageManager'; import { PRELOAD_CONFIG } from '../config/preload.config'; import { OFFLINE_CONFIG } from '../config/offline.config'; -import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; +import { + resolveAssetPlaybackUrl, + queuePresignedUrls, + isRelativeStoragePath, + markPresignedUrlFailed, + markPresignedUrlsVerified, + getPresignedUrl, +} from '../lib/assetUrl'; +import { baseURLApi } from '../config'; import { logger } from '../lib/logger'; + +/** + * Check if URL is a presigned S3 URL + */ +const isPresignedUrl = (url: string): boolean => { + return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature='); +}; + +/** + * Build proxy URL from storage key + */ +const buildProxyUrl = (storageKey: string): string => { + const normalizedPath = storageKey.replace(/^\/+/, ''); + return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`; +}; import type { PreloadPage, PreloadPageLink, @@ -33,6 +56,7 @@ interface UsePreloadOrchestratorOptions { interface PreloadQueueItem { id: string; url: string; + storageKey?: string; // Original storage key for presigned URL cache invalidation priority: number; assetType: 'image' | 'video' | 'audio' | 'transition' | 'other'; pageId: string; @@ -305,12 +329,46 @@ export function usePreloadOrchestrator( url: item.url.slice(-50), }); preloadedUrls.add(item.url); + // If this was a presigned URL, mark presigned URLs as verified + if (isPresignedUrl(item.url)) { + markPresignedUrlsVerified(); + } + // Also mark proxy URL as preloaded if we have a storage key + if (item.storageKey) { + const proxyUrl = buildProxyUrl(item.storageKey); + preloadedUrls.add(proxyUrl); + } }) - .catch((err) => { + .catch(async (err) => { logger.error('[PRELOAD] Download failed', { url: item.url.slice(-50), error: err?.message, }); + + // If presigned URL failed (e.g., CORS), retry with proxy URL + if (item.storageKey && isPresignedUrl(item.url)) { + markPresignedUrlFailed(item.storageKey); + const proxyUrl = buildProxyUrl(item.storageKey); + logger.info('[PRELOAD] Retrying with proxy URL', { + storageKey: item.storageKey.slice(-50), + proxyUrl: proxyUrl.slice(-60), + }); + + try { + await preloadWithProgress(proxyUrl, generateJobId(), item.id); + logger.info('[PRELOAD] Proxy download complete', { + url: proxyUrl.slice(-60), + }); + preloadedUrls.add(proxyUrl); + // Also mark original presigned URL as preloaded (for cache lookup) + preloadedUrls.add(item.url); + } catch (retryErr) { + logger.error('[PRELOAD] Proxy download also failed', { + url: proxyUrl.slice(-60), + error: retryErr instanceof Error ? retryErr.message : 'unknown', + }); + } + } }) .finally(() => { activeDownloadsRef.current--; @@ -453,72 +511,125 @@ export function usePreloadOrchestrator( assets: assets.map((a) => ({ type: a.assetType, url: a.url.slice(-50) })), }); - // Add background assets from pages + // Collect all raw storage paths that need presigning + const storagePaths: string[] = []; const currentPage = pages.find((p) => p.id === currentPageId); - if (currentPage?.background_image_url) { - const resolvedUrl = resolveAssetPlaybackUrl( - currentPage.background_image_url, - ); - if (resolvedUrl) { - addToQueue({ - id: `bg-img-${currentPageId}`, - url: resolvedUrl, - priority: PRELOAD_CONFIG.priority.currentPage + 200, - assetType: 'image', - pageId: currentPageId, - }); - } + + if (currentPage?.background_image_url && isRelativeStoragePath(currentPage.background_image_url)) { + storagePaths.push(currentPage.background_image_url); } - if (currentPage?.background_video_url) { - const resolvedUrl = resolveAssetPlaybackUrl( - currentPage.background_video_url, - ); - if (resolvedUrl) { - addToQueue({ - id: `bg-vid-${currentPageId}`, - url: resolvedUrl, - priority: PRELOAD_CONFIG.priority.currentPage + 150, - assetType: 'video', - pageId: currentPageId, - }); - } + if (currentPage?.background_video_url && isRelativeStoragePath(currentPage.background_video_url)) { + storagePaths.push(currentPage.background_video_url); } - // Add element assets assets.forEach((asset) => { - const resolvedUrl = resolveAssetPlaybackUrl(asset.url); - if (resolvedUrl) { - addToQueue({ - id: generateJobId(), - url: resolvedUrl, - priority: asset.priority, - assetType: asset.assetType, - pageId: asset.pageId, - }); + if (isRelativeStoragePath(asset.url)) { + storagePaths.push(asset.url); } }); - // If aggressive preloading, also preload neighbor backgrounds if (shouldPreloadAggressively) { const neighbors = neighborGraph.getNeighbors(currentPageId, 1); neighbors.forEach(({ pageId }) => { const page = pages.find((p) => p.id === pageId); - if (page?.background_image_url) { - const resolvedUrl = resolveAssetPlaybackUrl( - page.background_image_url, - ); - if (resolvedUrl) { - addToQueue({ - id: `bg-img-${pageId}`, - url: resolvedUrl, - priority: PRELOAD_CONFIG.priority.neighborBase, - assetType: 'image', - pageId, - }); - } + if (page?.background_image_url && isRelativeStoragePath(page.background_image_url)) { + storagePaths.push(page.background_image_url); } }); } + + // Batch fetch presigned URLs, then add to queue + const addAssetsToQueue = () => { + // Add background assets from current page + if (currentPage?.background_image_url) { + const storageKey = currentPage.background_image_url; + const resolvedUrl = resolveAssetPlaybackUrl(storageKey); + if (resolvedUrl) { + addToQueue({ + id: `bg-img-${currentPageId}`, + url: resolvedUrl, + storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, + priority: PRELOAD_CONFIG.priority.currentPage + 200, + assetType: 'image', + pageId: currentPageId, + }); + } + } + if (currentPage?.background_video_url) { + const storageKey = currentPage.background_video_url; + const resolvedUrl = resolveAssetPlaybackUrl(storageKey); + if (resolvedUrl) { + addToQueue({ + id: `bg-vid-${currentPageId}`, + url: resolvedUrl, + storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, + priority: PRELOAD_CONFIG.priority.currentPage + 150, + assetType: 'video', + pageId: currentPageId, + }); + } + } + + // Add element assets + assets.forEach((asset) => { + const storageKey = asset.url; + const resolvedUrl = resolveAssetPlaybackUrl(storageKey); + if (resolvedUrl) { + addToQueue({ + id: generateJobId(), + url: resolvedUrl, + storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, + priority: asset.priority, + assetType: asset.assetType, + pageId: asset.pageId, + }); + } + }); + + // If aggressive preloading, also preload neighbor backgrounds + if (shouldPreloadAggressively) { + const neighbors = neighborGraph.getNeighbors(currentPageId, 1); + neighbors.forEach(({ pageId }) => { + const page = pages.find((p) => p.id === pageId); + if (page?.background_image_url) { + const storageKey = page.background_image_url; + const resolvedUrl = resolveAssetPlaybackUrl(storageKey); + if (resolvedUrl) { + addToQueue({ + id: `bg-img-${pageId}`, + url: resolvedUrl, + storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, + priority: PRELOAD_CONFIG.priority.neighborBase, + assetType: 'image', + pageId, + }); + } + } + }); + } + }; + + // If there are storage paths to presign, fetch them first + if (storagePaths.length > 0) { + logger.info('[PRELOAD] Fetching presigned URLs', { + count: storagePaths.length, + }); + queuePresignedUrls(storagePaths) + .then(() => { + logger.info('[PRELOAD] Presigned URLs fetched, adding to queue'); + addAssetsToQueue(); + }) + .catch((error) => { + logger.error('[PRELOAD] Failed to fetch presigned URLs, falling back to proxy', { + error: error?.message, + }); + // Fallback: add to queue without presigned URLs (will use backend proxy) + addAssetsToQueue(); + }); + } else { + // No storage paths to presign, add directly to queue + addAssetsToQueue(); + } }, [ enabled, currentPageId, diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index 885cc20..9dbd315 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -8,6 +8,8 @@ import { } from 'react'; import axios from 'axios'; import { logger } from '../lib/logger'; +import { markPresignedUrlFailed, isRelativeStoragePath } from '../lib/assetUrl'; +import { baseURLApi } from '../config'; import { useReversePlayback } from './useReversePlayback'; export type ReverseMode = 'none' | 'reverse' | 'separate'; @@ -100,6 +102,40 @@ function buildBlobRequestUrl(url: string): string { return url; } +/** + * Check if a URL is a presigned S3 URL (contains X-Amz-Signature) + */ +function isPresignedUrl(url: string): boolean { + return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature='); +} + +/** + * Convert a presigned URL back to proxy URL + * Extracts the storage key from the S3 path and builds a proxy URL + */ +function getProxyUrlFallback(presignedUrl: string, originalStorageKey?: string): string | null { + // If we have the original storage key, use it directly + if (originalStorageKey && isRelativeStoragePath(originalStorageKey)) { + const normalizedPath = originalStorageKey.replace(/^\/+/, ''); + return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`; + } + + // Try to extract path from presigned URL + try { + const url = new URL(presignedUrl); + // S3 path format: /bucket-prefix/assets/project-id/filename.ext + const pathParts = url.pathname.split('/').filter(Boolean); + // Skip the bucket prefix, take the rest as the storage path + if (pathParts.length >= 2) { + const storagePath = pathParts.slice(1).join('/'); + return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(storagePath)}`; + } + } catch { + // URL parsing failed + } + return null; +} + async function waitForImages(urls: string[], timeoutMs = 2000): Promise { if (urls.length === 0) return; @@ -154,6 +190,8 @@ export function useTransitionPlayback( const activeSourceUrlRef = useRef(null); const lastLoadedBlobUrlRef = useRef(null); const lastLoadedSourceUrlRef = useRef(null); + const didTryFallbackRef = useRef(false); + const currentPlayableUrlRef = useRef(null); const startWatchdogTimerRef = useRef | null>( null, ); @@ -315,6 +353,8 @@ export function useTransitionPlayback( activeSourceUrlRef.current = sourceUrl; didFinishRef.current = false; didStartPlaybackRef.current = false; + didTryFallbackRef.current = false; + currentPlayableUrlRef.current = null; setPhase('preparing'); const isReverseMode = currentTransition.reverseMode === 'reverse'; @@ -469,6 +509,7 @@ export function useTransitionPlayback( video.currentTime = 0; video.load(); lastLoadedSourceUrlRef.current = playableSourceUrl; + currentPlayableUrlRef.current = playableSourceUrl; attemptPlay(); @@ -568,9 +609,38 @@ export function useTransitionPlayback( const onEnded = () => finishPlayback('ended'); - const onVideoError = () => { + const onVideoError = async () => { if (didFinishRef.current) return; logIssue('video-error'); + + // Check if this is a presigned URL failure (likely CORS) + const currentUrl = currentPlayableUrlRef.current; + if (currentUrl && isPresignedUrl(currentUrl) && !didTryFallbackRef.current) { + logger.info('Presigned URL failed, trying proxy fallback', { + url: currentUrl.slice(0, 80), + }); + + // Mark presigned URL as failed so future resolves use proxy + // Extract storage key from the original transition videoUrl + const originalVideoUrl = currentTransition.videoUrl; + if (originalVideoUrl && isRelativeStoragePath(originalVideoUrl)) { + markPresignedUrlFailed(originalVideoUrl); + } + + // Get proxy fallback URL + const fallbackUrl = getProxyUrlFallback(currentUrl, currentTransition.videoUrl); + if (fallbackUrl) { + didTryFallbackRef.current = true; + video.pause(); + video.src = fallbackUrl; + currentPlayableUrlRef.current = fallbackUrl; + video.currentTime = 0; + video.load(); + attemptPlay(); + return; + } + } + handleError('video-error'); }; diff --git a/frontend/src/lib/assetUrl.ts b/frontend/src/lib/assetUrl.ts index 4fb9cd3..0d56603 100644 --- a/frontend/src/lib/assetUrl.ts +++ b/frontend/src/lib/assetUrl.ts @@ -2,10 +2,292 @@ * Asset URL Resolution Utility * * Resolves relative asset paths to absolute backend URLs for playback and preloading. + * Supports presigned S3 URLs for direct, fast asset downloads. */ +import axios, { AxiosError } from 'axios'; import { baseURLApi } from '../config'; +/** + * Check if a URL is a presigned S3 URL + */ +const isPresignedS3Url = (url: string): boolean => { + return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature='); +}; + +/** + * Setup Axios interceptor to detect presigned URL failures. + * Called once during app initialization. + */ +export const setupPresignedUrlInterceptor = (): void => { + axios.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + const url = error.config?.url || ''; + + // Check if this is a presigned S3 URL failure (likely CORS) + if (isPresignedS3Url(url) && !presignedUrlsDisabled) { + console.info('[assetUrl] Presigned URL request failed, disabling presigned URLs'); + disablePresignedUrls(); + } + + return Promise.reject(error); + } + ); +}; + +/** + * Disable presigned URLs globally. + * Called when we detect S3 CORS is not configured. + */ +export const disablePresignedUrls = (): void => { + if (!presignedUrlsDisabled) { + presignedUrlsDisabled = true; + presignedUrlCache.clear(); + console.info('[assetUrl] Presigned URLs disabled - all requests will use proxy'); + } +}; + +interface PresignedUrlCache { + url: string; + expiresAt: number; +} + +// In-memory cache for presigned URLs +const presignedUrlCache = new Map(); +const CACHE_BUFFER_MS = 5 * 60 * 1000; // Refresh 5 min before expiry +const PRESIGN_TTL_MS = 60 * 60 * 1000; // 1 hour TTL + +// Global flag to disable presigned URLs when CORS is not configured +// Once set to true, all URL resolutions will use proxy URLs +let presignedUrlsDisabled = false; + +// Flag to track if presigned URLs have been verified to work +// Until verified, resolveAssetPlaybackUrl will use proxy URLs +let presignedUrlsVerified = false; + +// Batch queue for presigned URL requests +let pendingBatch: string[] = []; +let batchPromiseResolvers: Array<{ + resolve: (value: Record) => void; + reject: (reason?: unknown) => void; +}> = []; +let batchTimeout: ReturnType | null = null; +const BATCH_DELAY_MS = 10; // Small delay to batch concurrent requests + +/** + * Fetch presigned URLs for a batch of storage keys + */ +const fetchPresignedUrlsBatch = async ( + urls: string[], +): Promise> => { + const response = await axios.post<{ presignedUrls: Record }>( + `${baseURLApi}/file/presign`, + { urls }, + ); + + // Cache the results + const now = Date.now(); + Object.entries(response.data.presignedUrls).forEach(([key, url]) => { + presignedUrlCache.set(key, { + url, + expiresAt: now + PRESIGN_TTL_MS, + }); + }); + + return response.data.presignedUrls; +}; + +/** + * Process the pending batch of URLs + */ +const processBatch = async (): Promise => { + const urls = Array.from(new Set(pendingBatch)); // Deduplicate + const resolvers = [...batchPromiseResolvers]; + pendingBatch = []; + batchPromiseResolvers = []; + batchTimeout = null; + + if (urls.length === 0) { + resolvers.forEach(({ resolve }) => resolve({})); + return; + } + + try { + const result = await fetchPresignedUrlsBatch(urls); + resolvers.forEach(({ resolve }) => resolve(result)); + } catch (error) { + resolvers.forEach(({ reject }) => reject(error)); + } +}; + +/** + * Queue a URL for batch presigning and return a promise that resolves when the batch completes. + * Used by the preloader to efficiently fetch presigned URLs for multiple assets. + */ +export const queuePresignedUrl = (storageKey: string): Promise => { + // Check cache first + const cached = presignedUrlCache.get(storageKey); + if (cached && cached.expiresAt > Date.now() + CACHE_BUFFER_MS) { + return Promise.resolve(cached.url); + } + + // Add to pending batch + if (!pendingBatch.includes(storageKey)) { + pendingBatch.push(storageKey); + } + + // Return a promise that will be resolved when the batch is processed + return new Promise((resolve, reject) => { + batchPromiseResolvers.push({ + resolve: (result) => resolve(result[storageKey] || null), + reject, + }); + + // Schedule batch processing if not already scheduled + if (!batchTimeout) { + batchTimeout = setTimeout(processBatch, BATCH_DELAY_MS); + } + }); +}; + +/** + * Queue multiple URLs for batch presigning. + * More efficient than calling queuePresignedUrl multiple times. + * Returns empty object if presigned URLs are disabled. + */ +export const queuePresignedUrls = (storageKeys: string[]): Promise> => { + // Skip if presigned URLs are disabled + if (presignedUrlsDisabled) { + return Promise.resolve({}); + } + + // Filter out already cached URLs + const uncachedKeys = storageKeys.filter((key) => { + const cached = presignedUrlCache.get(key); + return !cached || cached.expiresAt <= Date.now() + CACHE_BUFFER_MS; + }); + + if (uncachedKeys.length === 0) { + // All URLs are cached, return from cache + const result: Record = {}; + storageKeys.forEach((key) => { + const cached = presignedUrlCache.get(key); + if (cached) { + result[key] = cached.url; + } + }); + return Promise.resolve(result); + } + + // Add uncached keys to pending batch + uncachedKeys.forEach((key) => { + if (!pendingBatch.includes(key)) { + pendingBatch.push(key); + } + }); + + // Return a promise that will be resolved when the batch is processed + return new Promise((resolve, reject) => { + batchPromiseResolvers.push({ + resolve: (result) => { + // Include cached URLs in the result + const finalResult: Record = {}; + storageKeys.forEach((key) => { + if (result[key]) { + finalResult[key] = result[key]; + } else { + const cached = presignedUrlCache.get(key); + if (cached) { + finalResult[key] = cached.url; + } + } + }); + resolve(finalResult); + }, + reject, + }); + + // Schedule batch processing if not already scheduled + if (!batchTimeout) { + batchTimeout = setTimeout(processBatch, BATCH_DELAY_MS); + } + }); +}; + +/** + * Flush the pending batch immediately and fetch presigned URLs. + * Call this before starting asset preloading to ensure all URLs are ready. + */ +export const flushPresignedUrlQueue = async (): Promise => { + if (batchTimeout) { + clearTimeout(batchTimeout); + batchTimeout = null; + } + await processBatch(); +}; + +/** + * Get a presigned URL from cache (synchronous). + * Returns null if not cached, expired, disabled, or not yet verified. + */ +export const getPresignedUrl = (storageKey: string): string | null => { + // Return null if presigned URLs are disabled or not verified yet + if (presignedUrlsDisabled || !presignedUrlsVerified) { + return null; + } + const cached = presignedUrlCache.get(storageKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.url; + } + return null; +}; + +/** + * Check if presigned URLs are currently disabled. + */ +export const arePresignedUrlsDisabled = (): boolean => { + return presignedUrlsDisabled; +}; + +/** + * Mark presigned URLs as verified (they work). + * Called by preloader after a successful presigned URL fetch. + */ +export const markPresignedUrlsVerified = (): void => { + if (!presignedUrlsDisabled && !presignedUrlsVerified) { + presignedUrlsVerified = true; + console.info('[assetUrl] Presigned URLs verified - enabling direct S3 access'); + } +}; + +/** + * Mark a presigned URL as failed (removes from cache). + * Call this when a fetch using the presigned URL fails (e.g., CORS error). + * This allows resolveAssetPlaybackUrl to fall back to the proxy URL. + * Also disables presigned URLs globally for this session. + */ +export const markPresignedUrlFailed = (storageKey: string): void => { + presignedUrlCache.delete(storageKey); + disablePresignedUrls(); +}; + +/** + * Check if a path is a relative storage path (not a full URL or special protocol) + */ +export const isRelativeStoragePath = (url: string): boolean => { + const normalized = url.trim(); + return ( + normalized.length > 0 && + !normalized.startsWith('http://') && + !normalized.startsWith('https://') && + !normalized.startsWith('data:') && + !normalized.startsWith('blob:') && + !normalized.startsWith('/api/file/download') && + !normalized.startsWith('/file/download') + ); +}; + /** * Resolves an asset path to its full playback URL. * @@ -14,7 +296,7 @@ import { baseURLApi } from '../config'; * - /api/file/download URLs (passthrough) * - /file/download URLs (prepend baseURLApi) * - Full http/https URLs (passthrough) - * - Relative paths (convert to /api/file/download?privateUrl=...) + * - Relative paths (check presigned cache, fallback to /api/file/download?privateUrl=...) * * @param value - The asset URL or path to resolve * @returns The resolved full URL, or empty string if no value @@ -38,7 +320,21 @@ export const resolveAssetPlaybackUrl = (value?: string): string => { if (normalized.startsWith('http://') || normalized.startsWith('https://')) return normalized; - // Relative path - convert to API download URL + // Relative path - check presigned URL cache first + const presigned = getPresignedUrl(normalized); + if (presigned) { + return presigned; + } + + // Fallback to backend proxy const normalizedPrivateUrl = normalized.replace(/^\/+/, ''); return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`; }; + +/** + * Clear the presigned URL cache. + * Useful when user logs out or storage provider changes. + */ +export const clearPresignedUrlCache = (): void => { + presignedUrlCache.clear(); +}; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index b2d6cab..649d3f3 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -23,6 +23,14 @@ import { } from '../stores/introSteps'; import { DownloadProvider } from '../context/DownloadContext'; import { logger } from '../lib/logger'; +import { disablePresignedUrls } from '../lib/assetUrl'; + +/** + * Check if a URL is a presigned S3 URL + */ +const isPresignedS3Url = (url: string): boolean => { + return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature='); +}; // Initialize axios axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API @@ -48,7 +56,7 @@ axios.interceptors.request.use( (error) => Promise.reject(error), ); -// Set up axios response interceptor to handle 401 errors and redirect to login +// Set up axios response interceptor to handle 401 errors and presigned URL failures axios.interceptors.response.use( (response) => response, (error) => { @@ -59,6 +67,15 @@ axios.interceptors.response.use( requestUrl.includes('/auth/signin/local') || requestUrl.includes('auth/signin/local'); + // Detect presigned S3 URL failures (CORS not configured) + // Network errors (status 0) or CORS errors typically indicate S3 CORS issues + if (isPresignedS3Url(requestUrl) && (!status || status === 0 || error.message?.includes('Network Error'))) { + logger.info('[axios] Presigned URL failed, disabling presigned URLs', { + url: requestUrl.slice(0, 80), + }); + disablePresignedUrls(); + } + if (status === 401 && !isLoginRequest) { // Clear stored tokens sessionStorage.removeItem('token');