diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 22ea1f915e..91272bce34 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -47,6 +47,10 @@ yarn build - `yarn typecheck` - Run TypeScript type checking - `yarn check` - Run Biome linting and formatting +### Running minio tests + +Tests for `@uppy/aws-s3` need a minio server running inside Docker, and in order to run those tests you must set the environment variable `VITE_MINIO_CONFIG="test_user,test_password,http://localhost:9002/test-bucket,us-east-1"` when running the tests. + ### Headless components When adding a new component to `@uppy/components`, you have to run `yarn migrate:components` from root diff --git a/.github/workflows/bundlers.yml b/.github/workflows/bundlers.yml index 0f2035da83..23bbf645e3 100644 --- a/.github/workflows/bundlers.yml +++ b/.github/workflows/bundlers.yml @@ -70,6 +70,12 @@ jobs: mkdir /tmp/artifacts && corepack yarn workspaces foreach --all --no-private pack --install-if-needed -o /tmp/artifacts/%s-${{ github.sha }}.tgz + - name: Debug packaged uppy dependencies + run: | + rm -rf /tmp/uppy-debug + mkdir -p /tmp/uppy-debug + tar -xzf /tmp/artifacts/uppy-${{ github.sha }}.tgz --strip-components 1 -C /tmp/uppy-debug + node -e 'const pkg=require("/tmp/uppy-debug/package.json");const fields=["dependencies","peerDependencies","optionalDependencies","devDependencies"];let found=false;for(const f of fields){if(!pkg[f])continue;for(const [k,v] of Object.entries(pkg[f])){if(String(v).startsWith("workspace:")){console.log(`${f}: ${k}=${v}`);found=true;}}}if(!found)console.log("No workspace:* entries in packaged uppy package.json");' - name: Upload artifact if: success() uses: actions/upload-artifact@v6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d657c7ef1..e4c8bd1472 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: COMPANION_DOMAIN: localhost:3020 COMPANION_PROTOCOL: http COMPANION_REDIS_URL: redis://localhost:6379 + VITE_MINIO_CONFIG: "test_user,test_password,http://localhost:9002/test-bucket,us-east-1" types: name: Types diff --git a/examples/aws-companion/main.js b/examples/aws-companion/main.js index 7c1dd7f432..55eb8e4502 100644 --- a/examples/aws-companion/main.js +++ b/examples/aws-companion/main.js @@ -23,5 +23,5 @@ uppy.use(Dashboard, { plugins: ['GoogleDrive', 'Webcam'], }) uppy.use(AwsS3, { - endpoint: 'http://localhost:3020', + companionEndpoint: 'http://localhost:3020', }) diff --git a/examples/aws-nodejs/README.md b/examples/aws-nodejs/README.md index 1fc8c7fb54..96d7d11a49 100644 --- a/examples/aws-nodejs/README.md +++ b/examples/aws-nodejs/README.md @@ -1,7 +1,13 @@ # Uppy + AWS S3 with Node.JS -A simple and fully working example of Uppy and AWS S3 storage with Node.js (and -Express.js). It uses presigned URL at the backend level. +A simple and fully working example of Uppy and AWS S3 storage with a Node.js +(Express.js) backend. It demonstrates two signing modes: + +- **Client-side signing (STS)** — The server issues temporary credentials via + `GET /s3/sts`. The browser signs S3 requests locally using SigV4. +- **Server-side signing (presigned URLs)** — The browser sends each S3 operation + to `POST /s3/presign`. The server generates a presigned URL; the browser uses + it directly. ## AWS Configuration @@ -13,8 +19,8 @@ out of the scope here. ### S3 Setup -Assuming you’re trying to setup the user `MY-UPPY-USER` to put the uploaded -files to the bucket `MY-UPPY-BUCKET`, here’s how you can allow `MY-UPPY-USER` to +Assuming you're trying to setup the user `MY-UPPY-USER` to put the uploaded +files to the bucket `MY-UPPY-BUCKET`, here's how you can allow `MY-UPPY-USER` to get STS Federated Token and upload files to `MY-UPPY-BUCKET`: 1. Set CORS settings on `MY-UPPY-BUCKET` bucket: @@ -54,8 +60,8 @@ get STS Federated Token and upload files to `MY-UPPY-BUCKET`: } ``` -3. Add the following Policy to `MY-UPPY-USER`: (if you don’t want to enable - signing on the client, you can skip this step) +3. Add the following Policy to `MY-UPPY-USER`: (required for client-side signing + via the STS endpoint) ```json { "Version": "2012-10-17", @@ -100,9 +106,9 @@ COMPANION_AWS_SECRET=… PORT=8080 ``` -N.B.: This example uses `COMPANION_AWS_` environnement variables to facilitate +N.B.: This example uses `COMPANION_AWS_` environment variables to facilitate integrations with other examples in this repository, but this example does _not_ -uses Companion at all. +use Companion at all. ## Enjoy it diff --git a/examples/aws-nodejs/index.js b/examples/aws-nodejs/index.js index 1010edf45f..b1dbb88f56 100644 --- a/examples/aws-nodejs/index.js +++ b/examples/aws-nodejs/index.js @@ -1,371 +1,39 @@ const path = require('node:path') -const crypto = require('node:crypto') const { existsSync } = require('node:fs') require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') }) const express = require('express') +const bodyParser = require('body-parser') const app = express() - const port = process.env.PORT ?? 8080 -const accessControlAllowOrigin = '*' // You should define the actual domain(s) that are allowed to make requests. -const bodyParser = require('body-parser') - -const { - S3Client, - AbortMultipartUploadCommand, - CompleteMultipartUploadCommand, - CreateMultipartUploadCommand, - ListPartsCommand, - PutObjectCommand, - UploadPartCommand, -} = require('@aws-sdk/client-s3') -const { getSignedUrl } = require('@aws-sdk/s3-request-presigner') -const { STSClient, GetFederationTokenCommand } = require('@aws-sdk/client-sts') - -const policy = { - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Action: ['s3:PutObject'], - Resource: [ - `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}/*`, - `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}`, - ], - }, - ], -} - -/** - * @type {S3Client} - */ -let s3Client - -/** - * @type {STSClient} - */ -let stsClient - -const expiresIn = 900 // Define how long until a S3 signature expires. - -function getS3Client() { - s3Client ??= new S3Client({ - region: process.env.COMPANION_AWS_REGION, - credentials: { - accessKeyId: process.env.COMPANION_AWS_KEY, - secretAccessKey: process.env.COMPANION_AWS_SECRET, - }, - forcePathStyle: process.env.COMPANION_AWS_FORCE_PATH_STYLE === 'true', - }) - return s3Client -} - -function getSTSClient() { - stsClient ??= new STSClient({ - region: process.env.COMPANION_AWS_REGION, - credentials: { - accessKeyId: process.env.COMPANION_AWS_KEY, - secretAccessKey: process.env.COMPANION_AWS_SECRET, - }, - }) - return stsClient -} - -// Generate a unique S3 key for the file -const generateS3Key = (filename) => `${crypto.randomUUID()}-${filename}` - -// Extract the file parameters from the request -const extractFileParameters = (req) => { - const isPostRequest = req.method === 'POST' - const params = isPostRequest ? req.body : req.query - - return { - filename: params.filename, - contentType: params.type, - } -} - -// Validate the file parameters -const validateFileParameters = (filename, contentType) => { - if (!filename || !contentType) { - throw new Error( - 'Missing required parameters: filename and content type are required', - ) - } -} - -app.use(bodyParser.urlencoded({ extended: true }), bodyParser.json()) - -app.get('/s3/sts', (req, res, next) => { - // Before giving the STS token to the client, you should first check is they - // are authorized to perform that operation, and if the request is legit. - // For the sake of simplification, we skip that check in this example. - - getSTSClient() - .send( - new GetFederationTokenCommand({ - Name: '123user', - // The duration, in seconds, of the role session. The value specified - // can range from 900 seconds (15 minutes) up to the maximum session - // duration set for the role. - DurationSeconds: expiresIn, - Policy: JSON.stringify(policy), - }), - ) - .then((response) => { - // Test creating multipart upload from the server — it works - // createMultipartUploadYo(response) - res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin) - res.setHeader('Cache-Control', `public,max-age=${expiresIn}`) - res.json({ - credentials: response.Credentials, - bucket: process.env.COMPANION_AWS_BUCKET, - region: process.env.COMPANION_AWS_REGION, - }) - }, next) -}) -const signOnServer = (req, res, next) => { - // Before giving the signature to the user, you should first check is they - // are authorized to perform that operation, and if the request is legit. - // For the sake of simplification, we skip that check in this example. - - const { filename, contentType } = extractFileParameters(req) - validateFileParameters(filename, contentType) - - // Generate S3 key and prepare command - const Key = generateS3Key(filename) - - getSignedUrl( - getS3Client(), - new PutObjectCommand({ - Bucket: process.env.COMPANION_AWS_BUCKET, - Key, - ContentType: contentType, - }), - { expiresIn }, - ).then((url) => { - res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin) - res.json({ - url, - method: 'PUT', - }) - res.end() - }, next) -} -app.get('/s3/params', signOnServer) -app.post('/s3/sign', signOnServer) - -// === === -// You can remove those endpoints if you only want to support the non-multipart uploads. - -app.post('/s3/multipart', (req, res, next) => { - const client = getS3Client() - const { type, metadata, filename } = req.body - if (typeof filename !== 'string') { - return res - .status(400) - .json({ error: 's3: content filename must be a string' }) - } - if (typeof type !== 'string') { - return res.status(400).json({ error: 's3: content type must be a string' }) - } - const Key = `${crypto.randomUUID()}-${filename}` - - const params = { - Bucket: process.env.COMPANION_AWS_BUCKET, - Key, - ContentType: type, - Metadata: metadata, - } - - const command = new CreateMultipartUploadCommand(params) - return client.send(command, (err, data) => { - if (err) { - next(err) - return - } - res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin) - res.json({ - key: data.Key, - uploadId: data.UploadId, - }) - }) -}) - -function validatePartNumber(partNumber) { - partNumber = Number(partNumber) - return Number.isInteger(partNumber) && partNumber >= 1 && partNumber <= 10_000 -} -app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => { - const { uploadId, partNumber } = req.params - const { key } = req.query - - if (!validatePartNumber(partNumber)) { - return res.status(400).json({ - error: 's3: the part number must be an integer between 1 and 10000.', - }) - } - if (typeof key !== 'string') { - return res.status(400).json({ - error: - 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"', - }) - } - - return getSignedUrl( - getS3Client(), - new UploadPartCommand({ - Bucket: process.env.COMPANION_AWS_BUCKET, - Key: key, - UploadId: uploadId, - PartNumber: partNumber, - Body: '', - }), - { expiresIn }, - ).then((url) => { - res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin) - res.json({ url, expires: expiresIn }) - }, next) -}) - -app.get('/s3/multipart/:uploadId', (req, res, next) => { - const client = getS3Client() - const { uploadId } = req.params - const { key } = req.query - - if (typeof key !== 'string') { - res.status(400).json({ - error: - 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"', - }) - return - } - - const parts = [] - - function listPartsPage(startsAt = undefined) { - client.send( - new ListPartsCommand({ - Bucket: process.env.COMPANION_AWS_BUCKET, - Key: key, - UploadId: uploadId, - PartNumberMarker: startsAt, - }), - (err, data) => { - if (err) { - next(err) - return - } - - parts.push(...data.Parts) - - // continue to get list of all uploaded parts until the IsTruncated flag is false - if (data.IsTruncated) { - listPartsPage(data.NextPartNumberMarker) - } else { - res.json(parts) - } - }, - ) - } - listPartsPage() -}) +app.use(bodyParser.json()) -function isValidPart(part) { - return ( - part && - typeof part === 'object' && - Number(part.PartNumber) && - typeof part.ETag === 'string' - ) -} -app.post('/s3/multipart/:uploadId/complete', (req, res, next) => { - const client = getS3Client() - const { uploadId } = req.params - const { key } = req.query - const { parts } = req.body +// --- S3 signing routes --- +app.use(require('./routes/sts')) +app.use(require('./routes/presign')) - if (typeof key !== 'string') { - return res.status(400).json({ - error: - 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"', - }) - } - if (!Array.isArray(parts) || !parts.every(isValidPart)) { - return res.status(400).json({ - error: 's3: `parts` must be an array of {ETag, PartNumber} objects.', - }) - } - - return client.send( - new CompleteMultipartUploadCommand({ - Bucket: process.env.COMPANION_AWS_BUCKET, - Key: key, - UploadId: uploadId, - MultipartUpload: { - Parts: parts, - }, - }), - (err, data) => { - if (err) { - next(err) - return - } - res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin) - res.json({ - location: data.Location, - }) - }, - ) -}) - -app.delete('/s3/multipart/:uploadId', (req, res, next) => { - const client = getS3Client() - const { uploadId } = req.params - const { key } = req.query - - if (typeof key !== 'string') { - return res.status(400).json({ - error: - 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"', - }) - } - - return client.send( - new AbortMultipartUploadCommand({ - Bucket: process.env.COMPANION_AWS_BUCKET, - Key: key, - UploadId: uploadId, - }), - (err) => { - if (err) { - next(err) - return - } - res.json({}) - }, - ) -}) - -// === === - -// === === +// --------------------------------------------------------------------------- +// Static file serving +// --------------------------------------------------------------------------- app.get('/', (req, res) => { - res.setHeader('Content-Type', 'text/html') const htmlPath = path.join(__dirname, 'public', 'index.html') - res.sendFile(htmlPath) + require('node:fs').readFile(htmlPath, 'utf8', (err, html) => { + if (err) return res.status(500).send('Error loading page') + // Inject bucket/region config so the client can read them. + const config = `` + res.setHeader('Content-Type', 'text/html') + res.send(html.replace('', `${config}`)) + }) }) app.get('/index.html', (req, res) => { res.setHeader('Location', '/').sendStatus(308).end() }) -app.get('/withCustomEndpoints.html', (req, res) => { - res.setHeader('Content-Type', 'text/html') - const htmlPath = path.join(__dirname, 'public', 'withCustomEndpoints.html') - res.sendFile(htmlPath) -}) app.get('/uppy.min.mjs', (req, res) => { res.setHeader('Content-Type', 'text/javascript') @@ -410,4 +78,3 @@ app.listen(port, () => { console.log(`Example app listening on port ${port}.`) console.log(`Visit http://localhost:${port}/ on your browser to try it.`) }) -// === === diff --git a/examples/aws-nodejs/public/index.html b/examples/aws-nodejs/public/index.html index 881a07567a..1173b0c2cf 100644 --- a/examples/aws-nodejs/public/index.html +++ b/examples/aws-nodejs/public/index.html @@ -2,69 +2,117 @@ - Uppy – AWS upload example + Uppy – AWS S3 upload example + -

AWS upload example

+

AWS S3 upload example

+

Demonstrates the @uppy/aws-s3 plugin with two signing modes.

+
- Sign on the server -
+ Sign on the client (STS credentials) +

+ Uses getCredentials to fetch temporary STS credentials from + /s3/sts. The browser signs all S3 requests locally using SigV4. +

+
+
- Sign on the client (if WebCrypto is available) -
+ Sign on the server (presigned URLs) +

+ Uses signRequest to call /s3/presign for each S3 + operation. The server generates presigned URLs; the browser uses them directly. +

+
- + diff --git a/examples/aws-nodejs/public/withCustomEndpoints.html b/examples/aws-nodejs/public/withCustomEndpoints.html deleted file mode 100644 index 2265a7bb70..0000000000 --- a/examples/aws-nodejs/public/withCustomEndpoints.html +++ /dev/null @@ -1,267 +0,0 @@ - - - - - Uppy – AWS upload example - - - -

AWS upload example

-
- - - - - diff --git a/examples/aws-nodejs/routes/presign.js b/examples/aws-nodejs/routes/presign.js new file mode 100644 index 0000000000..a1f4638ac2 --- /dev/null +++ b/examples/aws-nodejs/routes/presign.js @@ -0,0 +1,108 @@ +/** + * POST /s3/presign — Presigned URLs for server-side signing (signRequest) + * + * The client sends the S3 operation details (method, key, uploadId, etc.) + * and this endpoint returns a presigned URL. The browser then sends the + * actual request directly to S3 using that URL. + */ + +const { Router } = require('express') +const { + S3Client, + AbortMultipartUploadCommand, + CompleteMultipartUploadCommand, + CreateMultipartUploadCommand, + ListPartsCommand, + PutObjectCommand, + UploadPartCommand, +} = require('@aws-sdk/client-s3') +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner') + +const expiresIn = 900 // 15 minutes + +let s3Client +function getS3Client() { + s3Client ??= new S3Client({ + region: process.env.COMPANION_AWS_REGION, + credentials: { + accessKeyId: process.env.COMPANION_AWS_KEY, + secretAccessKey: process.env.COMPANION_AWS_SECRET, + }, + forcePathStyle: process.env.COMPANION_AWS_FORCE_PATH_STYLE === 'true', + }) + return s3Client +} + +const router = Router() + +router.post('/s3/presign', async (req, res, next) => { + // Before giving the presigned URL to the client, you should first check if + // they are authorized to perform that operation, and if the request is legit. + // For the sake of simplification, we skip that check in this example. + + try { + const { method, key, uploadId, partNumber, contentType } = req.body + const client = getS3Client() + const bucket = process.env.COMPANION_AWS_BUCKET + + if (!method || !key) { + return res.status(400).json({ error: 'method and key are required' }) + } + + let command + + if (method === 'PUT' && uploadId && partNumber) { + // UploadPart (multipart) + command = new UploadPartCommand({ + Bucket: bucket, + Key: key, + UploadId: uploadId, + PartNumber: parseInt(partNumber, 10), + }) + } else if (method === 'PUT' && !uploadId && !partNumber) { + // PutObject (simple upload) + command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType || 'application/octet-stream', + }) + } else if (method === 'POST' && !uploadId) { + // CreateMultipartUpload + command = new CreateMultipartUploadCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType || 'application/octet-stream', + }) + } else if (method === 'POST' && uploadId) { + // CompleteMultipartUpload + command = new CompleteMultipartUploadCommand({ + Bucket: bucket, + Key: key, + UploadId: uploadId, + }) + } else if (method === 'DELETE' && uploadId) { + // AbortMultipartUpload + command = new AbortMultipartUploadCommand({ + Bucket: bucket, + Key: key, + UploadId: uploadId, + }) + } else if (method === 'GET' && uploadId) { + // ListParts + command = new ListPartsCommand({ + Bucket: bucket, + Key: key, + UploadId: uploadId, + }) + } else { + return res.status(400).json({ error: 'Unsupported operation' }) + } + + const url = await getSignedUrl(client, command, { expiresIn }) + res.json({ url }) + } catch (err) { + next(err) + } +}) + +module.exports = router diff --git a/examples/aws-nodejs/routes/sts.js b/examples/aws-nodejs/routes/sts.js new file mode 100644 index 0000000000..b758eca636 --- /dev/null +++ b/examples/aws-nodejs/routes/sts.js @@ -0,0 +1,65 @@ +/** + * GET /s3/sts — Temporary credentials for client-side signing (getCredentials) + * + * Returns short-lived STS credentials so the browser can sign S3 requests + * locally using SigV4. The credentials are scoped to PutObject only. + */ + +const { Router } = require('express') +const { STSClient, GetFederationTokenCommand } = require('@aws-sdk/client-sts') + +const expiresIn = 900 // 15 minutes + +// IAM policy for the federated user — allows PutObject to the bucket. +const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['s3:PutObject'], + Resource: [ + `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}/*`, + `arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}`, + ], + }, + ], +} + +let stsClient +function getSTSClient() { + stsClient ??= new STSClient({ + region: process.env.COMPANION_AWS_REGION, + credentials: { + accessKeyId: process.env.COMPANION_AWS_KEY, + secretAccessKey: process.env.COMPANION_AWS_SECRET, + }, + }) + return stsClient +} + +const router = Router() + +router.get('/s3/sts', (req, res, next) => { + // Before giving the STS token to the client, you should first check if they + // are authorized to perform that operation, and if the request is legit. + // For the sake of simplification, we skip that check in this example. + + getSTSClient() + .send( + new GetFederationTokenCommand({ + Name: '123user', + DurationSeconds: expiresIn, + Policy: JSON.stringify(policy), + }), + ) + .then((response) => { + res.setHeader('Cache-Control', `public,max-age=${expiresIn}`) + res.json({ + credentials: response.Credentials, + bucket: process.env.COMPANION_AWS_BUCKET, + region: process.env.COMPANION_AWS_REGION, + }) + }, next) +}) + +module.exports = router diff --git a/examples/aws-php/main.js b/examples/aws-php/main.js index 4704663bff..fbff325cb0 100644 --- a/examples/aws-php/main.js +++ b/examples/aws-php/main.js @@ -11,34 +11,24 @@ uppy.use(Dashboard, { target: 'body', }) uppy.use(AwsS3, { - shouldUseMultipart: false, // The PHP backend only supports non-multipart uploads + // The PHP backend only signs single PutObject URLs (no multipart). + shouldUseMultipart: false, - getUploadParameters(file) { - // Send a request to our PHP signing endpoint. - return fetch('/s3-sign.php', { - method: 'post', - // Send and receive JSON. + // signRequest is called for each S3 operation. For single PUTs the + // request is `{ method: 'PUT', key }`. The server returns a presigned URL. + signRequest: async (request) => { + const response = await fetch('/s3-sign.php', { + method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', }, body: JSON.stringify({ - filename: file.name, - contentType: file.type, + method: request.method, + key: request.key, }), }) - .then((response) => { - // Parse the JSON response. - return response.json() - }) - .then((data) => { - // Return an object in the correct shape. - return { - method: data.method, - url: data.url, - fields: data.fields, - headers: data.headers, - } - }) + if (!response.ok) throw new Error('Failed to get presigned URL') + return response.json() }, }) diff --git a/examples/aws-php/s3-sign.php b/examples/aws-php/s3-sign.php index b6ea7b828f..6dae3d51c4 100644 --- a/examples/aws-php/s3-sign.php +++ b/examples/aws-php/s3-sign.php @@ -2,7 +2,14 @@ require 'vendor/autoload.php'; header('Access-Control-Allow-Origin: *'); -header("Access-Control-Allow-Headers: GET"); +header('Access-Control-Allow-Methods: POST, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type'); + +// Short-circuit CORS preflight before any further processing. +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} // CONFIG: Change these variables to a valid region and bucket. $awsEndpoint = getenv('COMPANION_AWS_ENDPOINT') ?: null; @@ -11,36 +18,56 @@ // Directory to place uploaded files in. $directory = 'uppy-php-example'; -// Create the S3 client. -$s3 = new Aws\S3\S3Client([ +// Read credentials from the repo's Companion-style env vars (matches the +// .env used by the rest of the examples). Fall back to the AWS SDK's default +// credential chain (~/.aws/credentials, AWS_ACCESS_KEY_ID, etc.) if these +// aren't set. +$awsKey = getenv('COMPANION_AWS_KEY') ?: null; +$awsSecret = getenv('COMPANION_AWS_SECRET') ?: null; + +$clientConfig = [ 'version' => 'latest', 'endpoint' => $awsEndpoint, 'region' => $awsRegion, -]); +]; +if ($awsKey && $awsSecret) { + $clientConfig['credentials'] = [ + 'key' => $awsKey, + 'secret' => $awsSecret, + ]; +} -// Retrieve data about the file to be uploaded from the request body. +$s3 = new Aws\S3\S3Client($clientConfig); + +// The @uppy/aws-s3 plugin sends `{ method, key }` per operation. +// This example only handles PUT (PutObject) — multipart is disabled client-side. $body = json_decode(file_get_contents('php://input')); -$filename = $body->filename; -$contentType = $body->contentType; +if (!is_object($body)) { + http_response_code(400); + header('content-type: application/json'); + echo json_encode(['error' => 'Invalid or empty JSON body']); + exit; +} + +$method = $body->method ?? 'PUT'; +$key = $body->key ?? null; + +if ($method !== 'PUT' || !$key) { + http_response_code(400); + header('content-type: application/json'); + echo json_encode(['error' => 'Only PUT requests with a key are supported']); + exit; +} -// Prepare a PutObject command. $command = $s3->getCommand('putObject', [ 'Bucket' => $bucket, - 'Key' => "{$directory}/{$filename}", - 'ContentType' => $contentType, - 'Body' => '', + 'Key' => "{$directory}/{$key}", ]); $request = $s3->createPresignedRequest($command, '+5 minutes'); header('content-type: application/json'); +// signRequest expects a `{ url }` response — no method/fields/headers. echo json_encode([ - 'method' => $request->getMethod(), 'url' => (string) $request->getUri(), - 'fields' => [], - // Also set the content-type header on the request, to make sure that it is the same as the one we used to generate the signature. - // Else, the browser picks a content-type as it sees fit. - 'headers' => [ - 'content-type' => $contentType, - ], ]); diff --git a/examples/companion-digitalocean-spaces/README.md b/examples/companion-digitalocean-spaces/README.md index a9b4a9cabb..f1cd992bc9 100644 --- a/examples/companion-digitalocean-spaces/README.md +++ b/examples/companion-digitalocean-spaces/README.md @@ -3,7 +3,7 @@ This example uses Uppy to upload files to a [DigitalOcean Space](https://digitaloceanspaces.com/). DigitalOcean Spaces has an identical API to S3, so we can use the -[AwsS3](https://uppy.io/docs/aws-s3-multipart) plugin. We use @uppy/companion +[AwsS3](https://uppy.io/docs/aws-s3) plugin. We use @uppy/companion with a [custom `endpoint` configuration](./server.cjs#L39) that points to DigitalOcean. @@ -30,7 +30,7 @@ copy the `.env.example` file: ``` To setup the CORS settings of your Spaces bucket in accordance with -[the plugin docs](https://uppy.io/docs/aws-s3-multipart/#setting-up-your-s3-bucket), +[the plugin docs](https://uppy.io/docs/aws-s3/#setting-up-your-s3-bucket), you can use the [example XML config file](./setcors.xml) with the [`s3cmd` CLI](https://docs.digitalocean.com/products/spaces/reference/s3cmd/): diff --git a/examples/companion-digitalocean-spaces/main.js b/examples/companion-digitalocean-spaces/main.js index c1be7320c6..4510daaea9 100644 --- a/examples/companion-digitalocean-spaces/main.js +++ b/examples/companion-digitalocean-spaces/main.js @@ -15,4 +15,4 @@ uppy.use(Dashboard, { }) // No client side changes needed! -uppy.use(AwsS3, { companionUrl: '/companion' }) +uppy.use(AwsS3, { companionEndpoint: '/companion' }) diff --git a/packages/@uppy/aws-s3/package.json b/packages/@uppy/aws-s3/package.json index 840997fad2..0cc3904d29 100644 --- a/packages/@uppy/aws-s3/package.json +++ b/packages/@uppy/aws-s3/package.json @@ -43,6 +43,7 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.362.0", + "@aws-sdk/client-sts": "^3.362.0", "@aws-sdk/s3-request-presigner": "^3.362.0", "@uppy/core": "workspace:^", "jsdom": "^26.1.0", diff --git a/packages/@uppy/aws-s3/src/HTTPCommunicationQueue.ts b/packages/@uppy/aws-s3/src/HTTPCommunicationQueue.ts deleted file mode 100644 index 43a30ab530..0000000000 --- a/packages/@uppy/aws-s3/src/HTTPCommunicationQueue.ts +++ /dev/null @@ -1,441 +0,0 @@ -import type { Body, Meta, UppyFile } from '@uppy/core' -import type { RateLimitedQueue, WrapPromiseFunctionType } from '@uppy/utils' -import type AwsS3Multipart from './index.js' -import type { - AwsS3MultipartOptions, - AwsS3UploadParameters, - uploadPartBytes, -} from './index.js' -import { type Chunk, pausingUploadReason } from './MultipartUploader.js' -import type { UploadPartBytesResult, UploadResult } from './utils.js' -import { throwIfAborted } from './utils.js' - -function removeMetadataFromURL(urlString: string) { - const urlObject = new URL(urlString) - urlObject.search = '' - urlObject.hash = '' - return urlObject.href -} - -export class HTTPCommunicationQueue { - #abortMultipartUpload!: WrapPromiseFunctionType< - AwsS3Multipart['abortMultipartUpload'] - > - - #cache = new WeakMap() - - #createMultipartUpload!: WrapPromiseFunctionType< - AwsS3Multipart['createMultipartUpload'] - > - - #fetchSignature!: WrapPromiseFunctionType['signPart']> - - #getUploadParameters!: WrapPromiseFunctionType< - AwsS3Multipart['getUploadParameters'] - > - - #listParts!: WrapPromiseFunctionType['listParts']> - - #previousRetryDelay!: number - - #requests - - #retryDelays!: { values: () => Iterator } - - #sendCompletionRequest!: WrapPromiseFunctionType< - AwsS3Multipart['completeMultipartUpload'] - > - - #setS3MultipartState - - #uploadPartBytes!: WrapPromiseFunctionType - - #getFile - - constructor( - requests: RateLimitedQueue, - options: AwsS3MultipartOptions, - setS3MultipartState: (file: UppyFile, result: UploadResult) => void, - getFile: (file: UppyFile) => UppyFile, - ) { - this.#requests = requests - this.#setS3MultipartState = setS3MultipartState - this.#getFile = getFile - this.setOptions(options) - } - - setOptions(options: Partial>): void { - const requests = this.#requests - - if ('abortMultipartUpload' in options) { - this.#abortMultipartUpload = requests.wrapPromiseFunction( - options.abortMultipartUpload as any, - { priority: 1 }, - ) - } - if ('createMultipartUpload' in options) { - this.#createMultipartUpload = requests.wrapPromiseFunction( - options.createMultipartUpload as any, - { priority: -1 }, - ) - } - if ('signPart' in options) { - this.#fetchSignature = requests.wrapPromiseFunction( - options.signPart as any, - ) - } - if ('listParts' in options) { - this.#listParts = requests.wrapPromiseFunction(options.listParts as any) - } - if ('completeMultipartUpload' in options) { - this.#sendCompletionRequest = requests.wrapPromiseFunction( - options.completeMultipartUpload as any, - { priority: 1 }, - ) - } - if ('retryDelays' in options) { - this.#retryDelays = options.retryDelays ?? [] - } - if ('uploadPartBytes' in options) { - this.#uploadPartBytes = requests.wrapPromiseFunction( - options.uploadPartBytes as any, - { priority: Infinity }, - ) - } - if ('getUploadParameters' in options) { - this.#getUploadParameters = requests.wrapPromiseFunction( - options.getUploadParameters as any, - ) - } - } - - async #shouldRetry(err: any, retryDelayIterator: Iterator) { - const requests = this.#requests - const status = err?.source?.status - - // TODO: this retry logic is taken out of Tus. We should have a centralized place for retrying, - // perhaps the rate limited queue, and dedupe all plugins with that. - if (status == null) { - return false - } - if (status === 403 && err.message === 'Request has expired') { - if (!requests.isPaused) { - // We don't want to exhaust the retryDelayIterator as long as there are - // more than one request in parallel, to give slower connection a chance - // to catch up with the expiry set in Companion. - if (requests.limit === 1 || this.#previousRetryDelay == null) { - const next = retryDelayIterator.next() - if (next == null || next.done) { - return false - } - // If there are more than 1 request done in parallel, the RLQ limit is - // decreased and the failed request is requeued after waiting for a bit. - // If there is only one request in parallel, the limit can't be - // decreased, so we iterate over `retryDelayIterator` as we do for - // other failures. - // `#previousRetryDelay` caches the value so we can re-use it next time. - this.#previousRetryDelay = next.value - } - // No need to stop the other requests, we just want to lower the limit. - requests.rateLimit(0) - await new Promise((resolve) => - setTimeout(resolve, this.#previousRetryDelay), - ) - } - } else if (status === 429) { - // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests. - if (!requests.isPaused) { - const next = retryDelayIterator.next() - if (next == null || next.done) { - return false - } - requests.rateLimit(next.value) - } - } else if (status > 400 && status < 500 && status !== 409) { - // HTTP 4xx, the server won't send anything, it's doesn't make sense to retry - return false - } else if (typeof navigator !== 'undefined' && navigator.onLine === false) { - // The navigator is offline, let's wait for it to come back online. - if (!requests.isPaused) { - requests.pause() - window.addEventListener( - 'online', - () => { - requests.resume() - }, - { once: true }, - ) - } - } else { - // Other error code means the request can be retried later. - const next = retryDelayIterator.next() - if (next == null || next.done) { - return false - } - await new Promise((resolve) => setTimeout(resolve, next.value)) - } - return true - } - - async getUploadId( - file: UppyFile, - signal: AbortSignal, - ): Promise { - let cachedResult: Promise | UploadResult | undefined - // As the cache is updated asynchronously, there could be a race condition - // where we just miss a new result so we loop here until we get nothing back, - // at which point it's out turn to create a new cache entry. - for (;;) { - if (file.data == null) throw new Error('File data is empty') - cachedResult = this.#cache.get(file.data) - if (cachedResult == null) break - try { - return await cachedResult - } catch { - // In case of failure, we want to ignore the cached error. - // At this point, either there's a new cached value, or we'll exit the loop a create a new one. - } - } - - const promise = this.#createMultipartUpload(this.#getFile(file), signal) - - const abortPromise = () => { - if (file.data == null) throw new Error('File data is empty') - promise.abort(signal.reason) - this.#cache.delete(file.data) - } - signal.addEventListener('abort', abortPromise, { once: true }) - this.#cache.set(file.data, promise) - promise.then( - async (result) => { - signal.removeEventListener('abort', abortPromise) - this.#setS3MultipartState(file, result) - if (file.data == null) throw new Error('File data is empty') - this.#cache.set(file.data, result) - }, - () => { - signal.removeEventListener('abort', abortPromise) - if (file.data == null) throw new Error('File data is empty') - this.#cache.delete(file.data) - }, - ) - - return promise - } - - async abortFileUpload(file: UppyFile): Promise { - if (file.data == null) throw new Error('File data is empty') - const result = this.#cache.get(file.data) - if (result == null) { - // If the createMultipartUpload request never was made, we don't - // need to send the abortMultipartUpload request. - return - } - // Remove the cache entry right away for follow-up requests do not try to - // use the soon-to-be aborted cached values. - this.#cache.delete(file.data) - this.#setS3MultipartState(file, Object.create(null)) - let awaitedResult: UploadResult - try { - awaitedResult = await result - } catch { - // If the cached result rejects, there's nothing to abort. - return - } - await this.#abortMultipartUpload(this.#getFile(file), awaitedResult) - } - - async #nonMultipartUpload( - file: UppyFile, - chunk: Chunk, - signal?: AbortSignal, - ) { - const { - method = 'POST', - url, - fields, - headers, - } = await this.#getUploadParameters(this.#getFile(file), { - signal, - }).abortOn(signal) - - let body: FormData | Blob - const data = chunk.getData() - if (method.toUpperCase() === 'POST') { - const formData = new FormData() - Object.entries(fields!).forEach(([key, value]) => - formData.set(key, value), - ) - formData.set('file', data) - body = formData - } else { - body = data - } - - const { onProgress, onComplete } = chunk - - const result = (await this.#uploadPartBytes({ - signature: { url, headers, method } as any, - body, - size: data.size, - onProgress, - onComplete, - signal, - }).abortOn(signal)) as unknown as B // todo this doesn't make sense - - // Note: `fields.key` is not returned by old Companion versions. - // See https://github.com/transloadit/uppy/pull/5602 - const key = fields?.key - this.#setS3MultipartState(file, { key: key! }) - - return { - ...result, - location: - (result.location as string | undefined) ?? removeMetadataFromURL(url), - bucket: fields?.bucket, - key, - } - } - - async uploadFile( - file: UppyFile, - chunks: Chunk[], - signal: AbortSignal, - ): Promise> { - throwIfAborted(signal) - if (chunks.length === 1 && !chunks[0].shouldUseMultipart) { - return this.#nonMultipartUpload(file, chunks[0], signal) - } - const { uploadId, key } = await this.getUploadId(file, signal) - throwIfAborted(signal) - try { - const parts = await Promise.all( - chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal)), - ) - throwIfAborted(signal) - return await this.#sendCompletionRequest( - this.#getFile(file), - { key, uploadId, parts, signal }, - signal, - ).abortOn(signal) - } catch (err) { - if (err?.cause !== pausingUploadReason && err?.name !== 'AbortError') { - // We purposefully don't wait for the promise and ignore its status, - // because we want the error `err` to bubble up ASAP to report it to the - // user. A failure to abort is not that big of a deal anyway. - this.abortFileUpload(file) - } - throw err - } - } - - restoreUploadFile(file: UppyFile, uploadIdAndKey: UploadResult): void { - if (file.data == null) throw new Error('File data is empty') - this.#cache.set(file.data, uploadIdAndKey) - } - - async resumeUploadFile( - file: UppyFile, - chunks: Array, - signal: AbortSignal, - ): Promise { - throwIfAborted(signal) - if ( - chunks.length === 1 && - chunks[0] != null && - !chunks[0].shouldUseMultipart - ) { - return this.#nonMultipartUpload(file, chunks[0], signal) - } - const { uploadId, key } = await this.getUploadId(file, signal) - throwIfAborted(signal) - const alreadyUploadedParts = await this.#listParts( - this.#getFile(file), - { uploadId, key, signal }, - signal, - ).abortOn(signal) - throwIfAborted(signal) - const parts = await Promise.all( - chunks.map((chunk, i) => { - const partNumber = i + 1 - const alreadyUploadedInfo = alreadyUploadedParts.find( - ({ PartNumber }) => PartNumber === partNumber, - ) - if (alreadyUploadedInfo == null) { - return this.uploadChunk(file, partNumber, chunk!, signal) - } - // Already uploaded chunks are set to null. If we are restoring the upload, we need to mark it as already uploaded. - chunk?.setAsUploaded?.() - return { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag } - }), - ) - throwIfAborted(signal) - return this.#sendCompletionRequest( - this.#getFile(file), - { key, uploadId, parts, signal }, - signal, - ).abortOn(signal) - } - - async uploadChunk( - file: UppyFile, - partNumber: number, - chunk: Chunk, - signal: AbortSignal, - ): Promise { - throwIfAborted(signal) - const { uploadId, key } = await this.getUploadId(file, signal) - - const signatureRetryIterator = this.#retryDelays.values() - const chunkRetryIterator = this.#retryDelays.values() - const shouldRetrySignature = () => { - const next = signatureRetryIterator.next() - if (next == null || next.done) { - return null - } - return next.value - } - - for (;;) { - throwIfAborted(signal) - const chunkData = chunk.getData() - const { onProgress, onComplete } = chunk - let signature: AwsS3UploadParameters - - try { - signature = await this.#fetchSignature(this.#getFile(file), { - // Always defined for multipart uploads - uploadId: uploadId!, - key, - partNumber, - body: chunkData, - signal, - }).abortOn(signal) - } catch (err) { - const timeout = shouldRetrySignature() - if (timeout == null || signal.aborted) { - throw err - } - await new Promise((resolve) => setTimeout(resolve, timeout)) - continue - } - - throwIfAborted(signal) - try { - return { - PartNumber: partNumber, - ...(await this.#uploadPartBytes({ - signature, - body: chunkData, - size: chunkData.size, - onProgress, - onComplete, - signal, - }).abortOn(signal)), - } - } catch (err) { - if (!(await this.#shouldRetry(err, chunkRetryIterator))) throw err - } - } - } -} diff --git a/packages/@uppy/aws-s3/src/MultipartUploader.ts b/packages/@uppy/aws-s3/src/MultipartUploader.ts deleted file mode 100644 index c2884f62b9..0000000000 --- a/packages/@uppy/aws-s3/src/MultipartUploader.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type { Uppy } from '@uppy/core' -import type { Body, Meta, UppyFile } from '@uppy/utils' -import { AbortController } from '@uppy/utils' -import type { HTTPCommunicationQueue } from './HTTPCommunicationQueue.js' - -const MB = 1024 * 1024 - -interface MultipartUploaderOptions { - getChunkSize?: (file: { size: number }) => number - onProgress?: (bytesUploaded: number, bytesTotal: number) => void - onPartComplete?: (part: { PartNumber: number; ETag: string }) => void - shouldUseMultipart?: boolean | ((file: UppyFile) => boolean) - onSuccess?: (result: B) => void - onError?: (err: unknown) => void - companionComm: HTTPCommunicationQueue - file: UppyFile - log: Uppy['log'] - - uploadId?: string - key: string -} - -const defaultOptions = { - getChunkSize(file: { size: number }) { - return Math.ceil(file.size / 10000) - }, - onProgress() {}, - onPartComplete() {}, - onSuccess() {}, - onError(err: unknown) { - throw err - }, -} satisfies Partial> - -export interface Chunk { - getData: () => Blob - onProgress: (ev: ProgressEvent) => void - onComplete: (etag: string) => void - shouldUseMultipart: boolean - setAsUploaded?: () => void -} - -function ensureInt(value: T): T extends number | string ? number : never { - if (typeof value === 'string') { - // @ts-expect-error TS is not able to recognize it's fine. - return parseInt(value, 10) - } - if (typeof value === 'number') { - // @ts-expect-error TS is not able to recognize it's fine. - return value - } - throw new TypeError('Expected a number') -} - -export const pausingUploadReason = Symbol('pausing upload, not an actual error') - -/** - * A MultipartUploader instance is used per file upload to determine whether a - * upload should be done as multipart or as a regular S3 upload - * (based on the user-provided `shouldUseMultipart` option value) and to manage - * the chunk splitting. - */ -class MultipartUploader { - options: MultipartUploaderOptions & - Required, keyof typeof defaultOptions>> - - #abortController = new AbortController() - - #chunks: Array = [] - - #chunkState: { uploaded: number; etag?: string; done?: boolean }[] = [] - - /** - * The (un-chunked) data to upload. - */ - #data: Blob - - #file: UppyFile - - #uploadHasStarted = false - - #onError: (err: unknown) => void - - #onSuccess: (result: B) => void - - #shouldUseMultipart: MultipartUploaderOptions['shouldUseMultipart'] - - #isRestoring: boolean - - #onReject = (err: unknown) => - (err as any)?.cause === pausingUploadReason ? null : this.#onError(err) - - #maxMultipartParts = 10_000 - - #minPartSize = 5 * MB - - constructor(data: Blob, options: MultipartUploaderOptions) { - this.options = { - ...defaultOptions, - ...options, - } - // Use default `getChunkSize` if it was null or something - this.options.getChunkSize ??= defaultOptions.getChunkSize - - this.#data = data - this.#file = options.file - this.#onSuccess = this.options.onSuccess - this.#onError = this.options.onError - this.#shouldUseMultipart = this.options.shouldUseMultipart - - // When we are restoring an upload, we already have an UploadId and a Key. Otherwise - // we need to call `createMultipartUpload` to get an `uploadId` and a `key`. - // Non-multipart uploads are not restorable. - this.#isRestoring = (options.uploadId && options.key) as any as boolean - - this.#initChunks() - } - - // initChunks checks the user preference for using multipart uploads (opts.shouldUseMultipart) - // and calculates the optimal part size. When using multipart part uploads every part except for the last has - // to be at least 5 MB and there can be no more than 10K parts. - // This means we sometimes need to change the preferred part size from the user in order to meet these requirements. - #initChunks() { - const fileSize = this.#data.size - const shouldUseMultipart = - typeof this.#shouldUseMultipart === 'function' - ? this.#shouldUseMultipart(this.#file) - : Boolean(this.#shouldUseMultipart) - - if (shouldUseMultipart && fileSize > this.#minPartSize) { - // At least 5MB per request: - let chunkSize = Math.max( - this.options.getChunkSize(this.#data) as number, // Math.max can take undefined but TS does not think so - this.#minPartSize, - ) - let arraySize = Math.floor(fileSize / chunkSize) - - // At most 10k requests per file: - if (arraySize > this.#maxMultipartParts) { - arraySize = this.#maxMultipartParts - chunkSize = fileSize / this.#maxMultipartParts - } - this.#chunks = Array(arraySize) - - for (let offset = 0, j = 0; offset < fileSize; offset += chunkSize, j++) { - const end = Math.min(fileSize, offset + chunkSize) - - // Defer data fetching/slicing until we actually need the data, because it's slow if we have a lot of files - const getData = () => { - const i2 = offset - return this.#data.slice(i2, end) - } - - this.#chunks[j] = { - getData, - onProgress: this.#onPartProgress(j), - onComplete: this.#onPartComplete(j), - shouldUseMultipart, - } - if (this.#isRestoring) { - const size = - offset + chunkSize > fileSize ? fileSize - offset : chunkSize - // setAsUploaded is called by listPart, to keep up-to-date the - // quantity of data that is left to actually upload. - this.#chunks[j]!.setAsUploaded = () => { - this.#chunks[j] = null - this.#chunkState[j].uploaded = size - } - } - } - } else { - this.#chunks = [ - { - getData: () => this.#data, - onProgress: this.#onPartProgress(0), - onComplete: this.#onPartComplete(0), - shouldUseMultipart, - }, - ] - } - - this.#chunkState = this.#chunks.map(() => ({ uploaded: 0 })) - } - - #createUpload() { - this.options.companionComm - .uploadFile( - this.#file, - this.#chunks as Chunk[], - this.#abortController.signal, - ) - .then(this.#onSuccess, this.#onReject) - this.#uploadHasStarted = true - } - - #resumeUpload() { - this.options.companionComm - .resumeUploadFile(this.#file, this.#chunks, this.#abortController.signal) - .then(this.#onSuccess, this.#onReject) - } - - #onPartProgress = (index: number) => (ev: ProgressEvent) => { - if (!ev.lengthComputable) return - - this.#chunkState[index].uploaded = ensureInt(ev.loaded) - - const totalUploaded = this.#chunkState.reduce((n, c) => n + c.uploaded, 0) - this.options.onProgress(totalUploaded, this.#data.size) - } - - #onPartComplete = (index: number) => (etag: string) => { - // This avoids the net::ERR_OUT_OF_MEMORY in Chromium Browsers. - this.#chunks[index] = null - this.#chunkState[index].etag = etag - this.#chunkState[index].done = true - - const part = { - PartNumber: index + 1, - ETag: etag, - } - this.options.onPartComplete(part) - } - - #abortUpload() { - this.#abortController.abort() - this.options.companionComm - .abortFileUpload(this.#file) - .catch((err: unknown) => this.options.log(err as Error)) - } - - start(): void { - if (this.#uploadHasStarted) { - if (!this.#abortController.signal.aborted) - this.#abortController.abort(pausingUploadReason) - this.#abortController = new AbortController() - this.#resumeUpload() - } else if (this.#isRestoring) { - this.options.companionComm.restoreUploadFile(this.#file, { - uploadId: this.options.uploadId, - key: this.options.key, - }) - this.#resumeUpload() - } else { - this.#createUpload() - } - } - - pause(): void { - this.#abortController.abort(pausingUploadReason) - // Swap it out for a new controller, because this instance may be resumed later. - this.#abortController = new AbortController() - } - - abort(opts?: { really?: boolean }): void { - if (opts?.really) this.#abortUpload() - else this.pause() - } - - private [Symbol.for('uppy test: getChunkState')]() { - return this.#chunkState - } -} - -export default MultipartUploader diff --git a/packages/@uppy/aws-s3/src/S3Uploader.ts b/packages/@uppy/aws-s3/src/S3Uploader.ts new file mode 100644 index 0000000000..fc6a80dc8a --- /dev/null +++ b/packages/@uppy/aws-s3/src/S3Uploader.ts @@ -0,0 +1,421 @@ +import { EventManager, type Uppy } from '@uppy/core' +import type { Body, LocalUppyFile, Meta } from '@uppy/utils' +import type S3Client from './s3-client/S3Client.js' + +/** Persisted S3 multipart state for Golden Retriever resume support */ +interface S3MultipartState { + uploadId: string + key: string +} + +declare module '@uppy/utils' { + // biome-ignore lint/correctness/noUnusedVariables: must match existing interface signature + export interface LocalUppyFile { + s3Multipart?: S3MultipartState + } + // biome-ignore lint/correctness/noUnusedVariables: must match existing interface signature + export interface RemoteUppyFile { + s3Multipart?: S3MultipartState + } +} + +// ============================================================================ +// Constants +// ============================================================================ + +const MB = 1024 * 1024 + +/** Minimum chunk size required by S3 (5MB) */ +const MIN_CHUNK_SIZE = 5 * MB + +/** Maximum number of parts allowed by S3 */ +const MAX_PARTS = 10000 + +// ============================================================================ +// S3Uploader Types +// ============================================================================ + +interface S3UploaderOptions { + uppy: Uppy + s3Client: S3Client + file: LocalUppyFile + metadata: Record + key: string + shouldUseMultipart?: boolean + getChunkSize?: (file: { size: number }) => number + onProgress?: (bytesUploaded: number, bytesTotal: number) => void + onPartComplete?: (part: { PartNumber: number; ETag: string }) => void + onSuccess?: (result: UploadResult) => void + onError?: (err: Error) => void + onAbort?: () => void + log?: Uppy['log'] +} + +export interface UploadResult { + location: string + key: string + /** Only returned for multipart uploads */ + uploadId?: string +} + +interface Chunk { + index: number + start: number + end: number + size: number +} + +interface ChunkState { + uploaded: number + etag?: string +} + +export default class S3Uploader { + readonly #data: NonNullable['data']> + #key: string | undefined + readonly #options: S3UploaderOptions + readonly #eventManager: EventManager + + #chunks: Chunk[] = [] + #chunkState: ChunkState[] = [] + #shouldUseMultipart: boolean = false + #uploadId?: string + #uploadHasStarted: boolean = false + #abortController: AbortController | undefined + + constructor(options: S3UploaderOptions) { + if (options.file.data == null) { + throw new Error(`File data is missing for file ${options.file.id}`) + } + this.#options = options + this.#data = options.file.data + this.#eventManager = new EventManager(options.uppy) + + // Detect resume state from file (persisted by Golden Retriever across page refreshes). + // Must run before #initChunks so it can force multipart mode for resumed uploads. + const resumeState = options.file.s3Multipart + if (resumeState) { + this.#key = resumeState.key + this.#uploadId = resumeState.uploadId + this.#uploadHasStarted = true + } + + const fileSize = options.file.data.size + + // Determine if we should use multipart + // If we're resuming a multipart upload, force multipart. Otherwise use + // the boolean option (true/false) and ensure the file is larger than + // S3's minimum chunk size when enabling multipart. + this.#shouldUseMultipart = + Boolean(resumeState) || + (this.#options.shouldUseMultipart === true && fileSize > MIN_CHUNK_SIZE) + + // Create chunks based on upload strategy + if (this.#shouldUseMultipart) { + // Calculate chunk size: at least MIN_CHUNK_SIZE, but may be larger for huge files + let chunkSize = this.#getChunkSize(fileSize) + chunkSize = Math.max(chunkSize, MIN_CHUNK_SIZE) + + // Ensure we don't exceed MAX_PARTS (S3 limit: 10,000 parts) + if (Math.ceil(fileSize / chunkSize) > MAX_PARTS) { + chunkSize = Math.ceil(fileSize / MAX_PARTS) + } + + // Create chunk definitions + for ( + let offset = 0, index = 0; + offset < fileSize; + offset += chunkSize, index++ + ) { + const end = Math.min(offset + chunkSize, fileSize) + this.#chunks.push({ index, start: offset, end, size: end - offset }) + } + } else { + // Simple upload: single chunk for the entire file + this.#chunks = [{ index: 0, start: 0, end: fileSize, size: fileSize }] + } + + this.#chunkState = this.#chunks.map(() => ({ uploaded: 0 })) + + // Setup events: + const fileId = this.#options.file.id + + this.#eventManager.onFileRemove(fileId, () => { + this.abort() + this.#options.onAbort?.() + }) + + this.#eventManager.onCancelAll(fileId, () => { + this.abort() + this.#options.onAbort?.() + }) + + this.#eventManager.onFilePause(fileId, (isPaused) => { + if (isPaused) { + this.pause() + } else { + this.start() + } + }) + + this.#eventManager.onPauseAll(fileId, () => { + this.pause() + }) + + this.#eventManager.onResumeAll(fileId, () => { + this.start() + }) + + this.#eventManager.onRetry(fileId, () => { + this.start() + }) + + this.#eventManager.onRetryAll(fileId, () => { + this.start() + }) + } + + #getChunkSize(fileSize: number): number { + if (this.#options.getChunkSize) { + return this.#options.getChunkSize({ size: fileSize }) + } + return Math.ceil(fileSize / MAX_PARTS) + } + + async start(): Promise { + // Abort any pending operations (if not already aborted) + this.#abortController?.abort() + // Always create a fresh AbortController (also for resume) + this.#abortController = new AbortController() + + try { + if (this.#uploadHasStarted) { + await this.#resumeUpload() + } else { + await this.#createUpload() + } + } catch (err) { + this.#onError(err as Error) + } + } + + pause(): void { + this.#abortController?.abort() + } + + /** + * + * @param opts - `abortInS3`: Whether to also abort the multipart upload in S3. Default: true. Set to false to keep the multipart upload in S3 active, allowing for manual cleanup later and preventing accidental data loss if the user later tries to resume the upload. + */ + abort(opts?: { abortInS3?: boolean }): void { + this.#abortController?.abort() + // Clean up event listeners + this.#eventManager.remove() + if (opts?.abortInS3 !== false && this.#uploadId) { + if (!this.#key) { + throw new Error('Missing S3 object key for aborting upload') + } + this.#options.s3Client + .abortMultipartUpload(this.#key, this.#uploadId) + .catch((abortErr) => { + this.#options.log?.(abortErr, 'warning') + }) + } + } + + async #createUpload(): Promise { + this.#uploadHasStarted = true + if (this.#shouldUseMultipart) { + await this.#uploadMultipart() + } else { + await this.#uploadNonMultipart() + } + } + + async #resumeUpload(): Promise { + if (!this.#uploadId) { + await this.#createUpload() + return + } + if (!this.#key) { + throw new Error('Missing S3 object key for resuming upload') + } + const existingParts = await this.#options.s3Client.listParts( + this.#uploadId, + this.#key, + ) + // Sync local state with S3 - mark already-uploaded parts + for (const part of existingParts) { + const chunkIndex = part.partNumber - 1 + if (chunkIndex >= 0 && chunkIndex < this.#chunkState.length) { + this.#chunkState[chunkIndex].uploaded = this.#chunks[chunkIndex].size + this.#chunkState[chunkIndex].etag = part.etag + } + } + // Emit progress update to reflect already-uploaded parts + this.#onProgress() + await this.#uploadRemainingParts() + } + + async #uploadNonMultipart(): Promise { + const signal = this.#abortController?.signal + signal?.throwIfAborted() + + const { location, key } = await this.#options.s3Client.putObject( + this.#options.key, + this.#data, + this.#options.file.type || 'application/octet-stream', + this.#options.metadata, + (bytesUploaded: number) => { + this.#chunkState[0].uploaded = bytesUploaded + this.#onProgress() + }, + signal, + ) + + this.#onSuccess({ + location, + key, + }) + } + + async #uploadMultipart(): Promise { + const signal = this.#abortController?.signal + signal?.throwIfAborted() + + const { uploadId, key } = + await this.#options.s3Client.createMultipartUpload( + this.#options.key, + this.#options.file.type || 'application/octet-stream', + this.#options.metadata, + ) + if (key == null) { + throw new Error( + 'S3 client did not return object key for multipart upload', + ) + } + + this.#key = key + + // Persist resume state so Golden Retriever can restore it after page refresh + this.#options.uppy.setFileState(this.#options.file.id, { + s3Multipart: { uploadId, key }, + }) + + this.#uploadId = uploadId + + await this.#uploadRemainingParts() + } + + async #uploadRemainingParts(): Promise { + const signal = this.#abortController?.signal + + for (let i = 0; i < this.#chunks.length; i++) { + signal?.throwIfAborted() + if (this.#chunkState[i].etag) continue // already uploaded + + const chunk = this.#chunks[i] + const partNumber = i + 1 + const chunkData = this.#data.slice(chunk.start, chunk.end) + const chunkIndex = i // Capture for closure (cannot use for-loop variable i directly in a closure) + + if (this.#key == null) { + throw new Error('Missing S3 object key for uploading part') + } + const { etag } = await this.#options.s3Client.uploadPart( + this.#key, + this.#uploadId!, + chunkData, + partNumber, + (bytesUploaded: number) => { + this.#chunkState[chunkIndex].uploaded = bytesUploaded + this.#onProgress() + }, + signal, + ) + + // after part finished uploading, update chunk state + this.#chunkState[i].uploaded = chunk.size + this.#chunkState[i].etag = etag + this.#onProgress() + + if (this.#options.onPartComplete) { + this.#options.onPartComplete({ + PartNumber: partNumber, + ETag: etag, + }) + } + } + + signal?.throwIfAborted() + + const parts = this.#chunkState.flatMap((state, i) => + state.etag ? [{ partNumber: i + 1, etag: state.etag }] : [], + ) + + if (this.#key == null) { + throw new Error('Missing S3 object key for completing multipart upload') + } + + const { location, key } = + await this.#options.s3Client.completeMultipartUpload( + this.#key, + this.#uploadId!, + parts, + ) + + this.#onSuccess({ + location, + key, + uploadId: this.#uploadId, + }) + } + + #onProgress(): void { + if (!this.#options.onProgress) return + const bytesUploaded = this.#chunkState.reduce( + (sum, state) => sum + state.uploaded, + 0, + ) + this.#options.onProgress(bytesUploaded, this.#data.size) + } + + #onSuccess(result: UploadResult): void { + // If the upload was aborted (file removed mid-upload), the network request + // may still complete successfully. Don't emit success in this case since + // the file no longer exists in Uppy's state. + this.#eventManager.remove() + if (this.#abortController?.signal.aborted) { + return + } + // Clear persisted resume state — upload completed successfully. + this.#options.uppy.setFileState(this.#options.file.id, { + s3Multipart: undefined, + }) + this.#options.onSuccess?.(result) + } + + #onError(err: Error): void { + // ignore abort signals from intentional cancellation + if (err.name === 'AbortError') return + // If we intentionally aborted, don't report any subsequent errors + // (e.g., S3 returning 404 NoSuchUpload after we aborted the upload) + if (this.#abortController?.signal.aborted) return + + // Clean up event listeners so this uploader doesn't become a "zombie" + // that reacts to future retry/pause/resume events after the error. + // Without this, each failed retry leaves an orphaned uploader that + // still listens for retry-all, causing duplicate uploads on the next + // successful retry. + this.#eventManager.remove() + + // NOTE: We intentionally do NOT abort the multipart upload in S3 here. + // This allows the user to retry and resume from where they left off. + // The multipart upload is only aborted when the user cancels via the + // `abort()` method. By default `abort()` will also abort the multipart + // upload in S3 (abortInS3 = true). Pass { abortInS3: false } to keep the + // multipart upload in S3 so it can be cleaned up manually or resumed later. + + this.#options.onError?.(err) + } +} diff --git a/packages/@uppy/aws-s3/src/createSignedURL.test.ts b/packages/@uppy/aws-s3/src/createSignedURL.test.ts deleted file mode 100644 index f3a9e7a44c..0000000000 --- a/packages/@uppy/aws-s3/src/createSignedURL.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import assert from 'node:assert' -import { - PutObjectCommand, - S3Client, - UploadPartCommand, -} from '@aws-sdk/client-s3' -import { RequestChecksumCalculation } from '@aws-sdk/middleware-flexible-checksums' -import { getSignedUrl } from '@aws-sdk/s3-request-presigner' -import { afterEach, beforeEach, describe, it } from 'vitest' -import createSignedURL from './createSignedURL.js' - -const bucketName = 'some-bucket.with.dots' -const s3ClientOptions = { - region: 'us-bar-1', - credentials: { - accessKeyId: 'foo', - secretAccessKey: 'bar', - sessionToken: 'foobar', - }, - // AWS SDK v3 started enabling request checksums by default, which changes - // the presigned URL shape/signature. Keep tests aligned with our signer. - requestChecksumCalculation: RequestChecksumCalculation.WHEN_REQUIRED, -} -const { Date: OriginalDate } = globalThis - -describe('createSignedURL', () => { - beforeEach(() => { - const now_ms = OriginalDate.now() - // @ts-expect-error we're touching globals for test purposes. - // biome-ignore lint/suspicious/noShadowRestrictedNames: ... - globalThis.Date = function Date() { - if (new.target) { - return Reflect.construct(OriginalDate, [now_ms]) - } - return Reflect.apply(OriginalDate, this, [now_ms]) - } - globalThis.Date.now = function now() { - return now_ms - } - }) - afterEach(() => { - globalThis.Date = OriginalDate - }) - it('should be able to sign non-multipart upload', async () => { - const client = new S3Client(s3ClientOptions) - assert.strictEqual( - ( - await createSignedURL({ - accountKey: s3ClientOptions.credentials.accessKeyId, - accountSecret: s3ClientOptions.credentials.secretAccessKey, - sessionToken: s3ClientOptions.credentials.sessionToken, - bucketName, - Key: 'some/key', - Region: s3ClientOptions.region, - expires: 900, - }) - ).searchParams.get('X-Amz-Signature'), - new URL( - await getSignedUrl( - client, - new PutObjectCommand({ - Bucket: bucketName, - Key: 'some/key', - }), - { expiresIn: 900 }, - ), - ).searchParams.get('X-Amz-Signature'), - ) - }) - it('should be able to sign multipart upload', async () => { - const client = new S3Client(s3ClientOptions) - const partNumber = 99 - const uploadId = 'dummyUploadId' - assert.strictEqual( - ( - await createSignedURL({ - accountKey: s3ClientOptions.credentials.accessKeyId, - accountSecret: s3ClientOptions.credentials.secretAccessKey, - sessionToken: s3ClientOptions.credentials.sessionToken, - uploadId, - partNumber, - bucketName, - Key: 'some/key', - Region: s3ClientOptions.region, - expires: 900, - }) - ).searchParams.get('X-Amz-Signature'), - new URL( - await getSignedUrl( - client, - new UploadPartCommand({ - Bucket: bucketName, - UploadId: uploadId, - PartNumber: partNumber, - Key: 'some/key', - }), - { expiresIn: 900 }, - ), - ).searchParams.get('X-Amz-Signature'), - ) - }) - - it('should escape path and query as restricted to RFC 3986', async () => { - const client = new S3Client(s3ClientOptions) - const partNumber = 99 - const specialChars = ";?:@&=+$,#!'()" - const uploadId = `Upload${specialChars}Id` - // '.*' chars of path should be encoded - const Key = `${specialChars}.*/${specialChars}.*.ext` - const implResult = await createSignedURL({ - accountKey: s3ClientOptions.credentials.accessKeyId, - accountSecret: s3ClientOptions.credentials.secretAccessKey, - sessionToken: s3ClientOptions.credentials.sessionToken, - uploadId, - partNumber, - bucketName, - Key, - Region: s3ClientOptions.region, - expires: 900, - }) - const sdkResult = new URL( - await getSignedUrl( - client, - new UploadPartCommand({ - Bucket: bucketName, - UploadId: uploadId, - PartNumber: partNumber, - Key, - }), - { expiresIn: 900 }, - ), - ) - assert.strictEqual(implResult.pathname, sdkResult.pathname) - - const extractUploadId = /([?&])uploadId=([^&]+?)(&|$)/ - const extractSignature = /([?&])X-Amz-Signature=([^&]+?)(&|$)/ - assert.strictEqual( - implResult.search.match(extractUploadId)![2], - sdkResult.search.match(extractUploadId)![2], - ) - assert.strictEqual( - implResult.search.match(extractSignature)![2], - sdkResult.search.match(extractSignature)![2], - ) - }) -}) diff --git a/packages/@uppy/aws-s3/src/createSignedURL.ts b/packages/@uppy/aws-s3/src/createSignedURL.ts deleted file mode 100644 index a10fcb800b..0000000000 --- a/packages/@uppy/aws-s3/src/createSignedURL.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Create a canonical request by concatenating the following strings, separated - * by newline characters. This helps ensure that the signature that you - * calculate and the signature that AWS calculates can match. - * - * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-canonical-request - * - * @param param0 - * @param param0.method – The HTTP method. - * @param param0.CanonicalUri – The URI-encoded version of the absolute - * path component URL (everything between the host and the question mark - * character (?) that starts the query string parameters). If the absolute path - * is empty, use a forward slash character (/). - * @param param0.CanonicalQueryString – The URL-encoded query string - * parameters, separated by ampersands (&). Percent-encode reserved characters, - * including the space character. Encode names and values separately. If there - * are empty parameters, append the equals sign to the parameter name before - * encoding. After encoding, sort the parameters alphabetically by key name. If - * there is no query string, use an empty string (""). - * @param param0.SignedHeaders – The request headers, - * that will be signed, and their values, separated by newline characters. - * For the values, trim any leading or trailing spaces, convert sequential - * spaces to a single space, and separate the values for a multi-value header - * using commas. You must include the host header (HTTP/1.1), and any x-amz-* - * headers in the signature. You can optionally include other standard headers - * in the signature, such as content-type. - * @param param0.HashedPayload – A string created using the payload in - * the body of the HTTP request as input to a hash function. This string uses - * lowercase hexadecimal characters. If the payload is empty, use an empty - * string as the input to the hash function. - */ -function createCanonicalRequest({ - method = 'PUT', - CanonicalUri = '/', - CanonicalQueryString = '', - SignedHeaders, - HashedPayload, -}: { - method?: string - CanonicalUri: string - CanonicalQueryString: string - SignedHeaders: Record - HashedPayload: string -}): string { - const headerKeys = Object.keys(SignedHeaders) - .map((k) => k.toLowerCase()) - .sort() - return [ - method, - CanonicalUri, - CanonicalQueryString, - ...headerKeys.map((k) => `${k}:${SignedHeaders[k]}`), - '', - headerKeys.join(';'), - HashedPayload, - ].join('\n') -} - -const ec = new TextEncoder() -const algorithm = { name: 'HMAC', hash: 'SHA-256' } - -async function digest(data: string): ReturnType { - const { subtle } = globalThis.crypto - return subtle.digest(algorithm.hash, ec.encode(data)) -} - -async function generateHmacKey(secret: string | ArrayBuffer) { - const { subtle } = globalThis.crypto - return subtle.importKey( - 'raw', - typeof secret === 'string' ? ec.encode(secret) : secret, - algorithm, - false, - ['sign'], - ) -} - -function arrayBufferToHexString(arrayBuffer: ArrayBuffer) { - const byteArray = new Uint8Array(arrayBuffer) - let hexString = '' - for (let i = 0; i < byteArray.length; i++) { - hexString += byteArray[i].toString(16).padStart(2, '0') - } - return hexString -} - -async function hash(key: Parameters[0], data: string) { - const { subtle } = globalThis.crypto - return subtle.sign(algorithm, await generateHmacKey(key), ec.encode(data)) -} - -/** - * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html - */ -export default async function createSignedURL({ - accountKey, - accountSecret, - sessionToken, - bucketName, - Key, - Region, - expires, - uploadId, - partNumber, -}: { - accountKey: string - accountSecret: string - sessionToken: string - bucketName: string - Key: string - Region: string - expires: string | number - uploadId?: string - partNumber?: string | number -}): Promise { - const Service = 's3' - const host = `${Service}.${Region}.amazonaws.com` - /** - * List of char out of `encodeURI()` is taken from ECMAScript spec. - * Note that the `/` character is purposefully not included in list below. - * - * @see https://tc39.es/ecma262/#sec-encodeuri-uri - */ - const CanonicalUri = `/${bucketName}/${encodeURI(Key).replace(/[;?:@&=+$,#!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}` - const payload = 'UNSIGNED-PAYLOAD' - - const requestDateTime = new Date().toISOString().replace(/[-:]|\.\d+/g, '') // YYYYMMDDTHHMMSSZ - const date = requestDateTime.slice(0, 8) // YYYYMMDD - const scope = `${date}/${Region}/${Service}/aws4_request` - - const url = new URL(`https://${host}${CanonicalUri}`) - // N.B.: URL search params needs to be added in the ASCII order - url.searchParams.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256') - url.searchParams.set('X-Amz-Content-Sha256', payload) - url.searchParams.set('X-Amz-Credential', `${accountKey}/${scope}`) - url.searchParams.set('X-Amz-Date', requestDateTime) - url.searchParams.set('X-Amz-Expires', expires as string) - // We are signing on the client, so we expect there's going to be a session token: - url.searchParams.set('X-Amz-Security-Token', sessionToken) - url.searchParams.set('X-Amz-SignedHeaders', 'host') - // Those two are present only for Multipart Uploads: - if (partNumber) url.searchParams.set('partNumber', partNumber as string) - if (uploadId) url.searchParams.set('uploadId', uploadId) - url.searchParams.set( - 'x-id', - partNumber && uploadId ? 'UploadPart' : 'PutObject', - ) - - // Step 1: Create a canonical request - const canonical = createCanonicalRequest({ - CanonicalUri, - CanonicalQueryString: url.search.slice(1), - SignedHeaders: { - host, - }, - HashedPayload: payload, - }) - - // Step 2: Create a hash of the canonical request - const hashedCanonical = arrayBufferToHexString(await digest(canonical)) - - // Step 3: Create a string to sign - const stringToSign = [ - `AWS4-HMAC-SHA256`, // The algorithm used to create the hash of the canonical request. - requestDateTime, // The date and time used in the credential scope. - scope, // The credential scope. This restricts the resulting signature to the specified Region and service. - hashedCanonical, // The hash of the canonical request. - ].join('\n') - - // Step 4: Calculate the signature - const kDate = await hash(`AWS4${accountSecret}`, date) - const kRegion = await hash(kDate, Region) - const kService = await hash(kRegion, Service) - const kSigning = await hash(kService, 'aws4_request') - const signature = arrayBufferToHexString(await hash(kSigning, stringToSign)) - - // Step 5: Add the signature to the request - url.searchParams.set('X-Amz-Signature', signature) - - return url -} diff --git a/packages/@uppy/aws-s3/src/index.test.ts b/packages/@uppy/aws-s3/src/index.test.ts deleted file mode 100644 index f3061f79a8..0000000000 --- a/packages/@uppy/aws-s3/src/index.test.ts +++ /dev/null @@ -1,794 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - type Mock, - vi, -} from 'vitest' - -import 'whatwg-fetch' -import Core, { type Meta, type UppyFile } from '@uppy/core' -import nock from 'nock' -import AwsS3Multipart, { - type AwsBody, - type AwsS3MultipartOptions, -} from './index.js' - -const KB = 1024 -const MB = KB * KB - -describe('AwsS3Multipart', () => { - beforeEach(() => nock.disableNetConnect()) - - it('Registers AwsS3Multipart upload plugin', () => { - const core = new Core().use(AwsS3Multipart) - - // @ts-expect-error private property - const pluginNames = core[Symbol.for('uppy test: getPlugins')]( - 'uploader', - ).map((plugin: AwsS3Multipart) => plugin.constructor.name) - expect(pluginNames).toContain('AwsS3Multipart') - }) - - describe('defaultOptions', () => { - let opts: Partial> - - beforeEach(() => { - const core = new Core().use(AwsS3Multipart) - const awsS3Multipart = core.getPlugin('AwsS3Multipart')! - opts = awsS3Multipart.opts - }) - - it('allowedMetaFields is true', () => { - expect(opts.allowedMetaFields).toBe(true) - }) - - it('limit is 6', () => { - expect(opts.limit).toBe(6) - }) - - it('getTemporarySecurityCredentials is false', () => { - expect(opts.getTemporarySecurityCredentials).toBe(false) - }) - - describe('shouldUseMultipart', () => { - const MULTIPART_THRESHOLD = 100 * MB - - let shouldUseMultipart: (file: UppyFile) => boolean - - beforeEach(() => { - shouldUseMultipart = opts.shouldUseMultipart as ( - file: UppyFile, - ) => boolean - }) - - const createFile = (size: number): UppyFile => ({ - name: '', - size, - data: new Blob(), - extension: '', - id: '', - isRemote: false, - isGhost: false, - meta: undefined, - progress: { - percentage: 0, - bytesUploaded: 0, - bytesTotal: size, - uploadComplete: false, - uploadStarted: 0, - }, - type: '', - }) - - it('returns true for files larger than 100MB', () => { - const file = createFile(MULTIPART_THRESHOLD + 1) - expect(shouldUseMultipart(file)).toBe(true) - }) - - it('returns false for files exactly 100MB', () => { - const file = createFile(MULTIPART_THRESHOLD) - expect(shouldUseMultipart(file)).toBe(false) - }) - - it('returns false for files smaller than 100MB', () => { - const file = createFile(MULTIPART_THRESHOLD - 1) - expect(shouldUseMultipart(file)).toBe(false) - }) - - it('returns true for large files (~70GB)', () => { - const file = createFile(70 * 1024 * MB) - expect(shouldUseMultipart(file)).toBe(true) - }) - - it('returns true for very large files (~400GB)', () => { - const file = createFile(400 * 1024 * MB) - expect(shouldUseMultipart(file)).toBe(true) - }) - - it('returns false for files with size 0', () => { - const file = createFile(0) - expect(shouldUseMultipart(file)).toBe(false) - }) - }) - - it('retryDelays is [0, 1000, 3000, 5000]', () => { - expect(opts.retryDelays).toEqual([0, 1000, 3000, 5000]) - }) - }) - - describe('companionUrl assertion', () => { - it('Throws an error for main functions if configured without companionUrl', () => { - const core = new Core().use(AwsS3Multipart) - const awsS3Multipart = core.getPlugin('AwsS3Multipart')! - - const err = 'Expected a `endpoint` option' - const file = {} as unknown as UppyFile> - const opts = {} as any - - expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(err) - expect(() => awsS3Multipart.opts.listParts(file, opts)).toThrow(err) - expect(() => - awsS3Multipart.opts.completeMultipartUpload(file, opts), - ).toThrow(err) - expect(() => - awsS3Multipart.opts.abortMultipartUpload(file, opts), - ).toThrow(err) - expect(() => awsS3Multipart.opts.signPart(file, opts)).toThrow(err) - }) - }) - - describe('non-multipart upload', () => { - it('should handle POST uploads', async () => { - const core = new Core().use(AwsS3Multipart, { - shouldUseMultipart: false, - limit: 0, - getUploadParameters: () => ({ - method: 'POST', - url: 'https://bucket.s3.us-east-2.amazonaws.com/', - fields: { - key: 'file', - bucket: 'https://bucket.s3.us-east-2.amazonaws.com/', - }, - }), - }) - const scope = nock( - 'https://bucket.s3.us-east-2.amazonaws.com', - ).defaultReplyHeaders({ - 'access-control-allow-headers': '*', - 'access-control-allow-method': 'POST', - 'access-control-allow-origin': '*', - 'access-control-expose-headers': 'ETag, Location', - }) - - scope.options('/').reply(204, '') - scope - .post('/') - .reply(201, '', { ETag: 'test', Location: 'http://example.com' }) - - const fileSize = 1 - - core.addFile({ - source: 'vi', - name: 'multitest.dat', - type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], '', { - type: 'application/octet-stream', - }), - }) - - const uploadSuccessHandler = vi.fn() - core.on('upload-success', uploadSuccessHandler) - - await core.upload() - - expect(uploadSuccessHandler.mock.calls).toHaveLength(1) - expect(uploadSuccessHandler.mock.calls[0][1]).toStrictEqual({ - body: { - ETag: 'test', - etag: 'test', - location: 'http://example.com', - key: 'file', - bucket: 'https://bucket.s3.us-east-2.amazonaws.com/', - }, - status: 200, - uploadURL: 'http://example.com', - }) - - scope.done() - }) - }) - - describe('without companionUrl (custom main functions)', () => { - let core: Core - let awsS3Multipart: AwsS3Multipart - - beforeEach(() => { - core = new Core() - core.use(AwsS3Multipart, { - shouldUseMultipart: true, - limit: 0, - createMultipartUpload: vi.fn(() => { - return { - uploadId: '6aeb1980f3fc7ce0b5454d25b71992', - key: 'test/upload/multitest.dat', - } - }), - completeMultipartUpload: vi.fn(async () => ({ location: 'test' })), - abortMultipartUpload: vi.fn(), - signPart: vi.fn(async (file, { number }) => { - return { - url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${number}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`, - headers: number === 1 ? { 'Content-MD5': 'foo' } : undefined, - } - }), - listParts: undefined as any, - }) - awsS3Multipart = core.getPlugin('AwsS3Multipart')! - }) - - it('Keeps chunks marked as busy through retries until they complete', async () => { - const scope = nock( - 'https://bucket.s3.us-east-2.amazonaws.com', - ).defaultReplyHeaders({ - 'access-control-allow-headers': '*', - 'access-control-allow-method': 'PUT', - 'access-control-allow-origin': '*', - 'access-control-expose-headers': 'ETag', - }) - - const fileSize = 50 * MB - - scope - .options((uri) => uri.includes('test/upload/multitest.dat')) - .reply(200, '') - scope - .put( - (uri) => - uri.includes('test/upload/multitest.dat') && - !uri.includes('partNumber=7'), - ) - .reply(200, '', { ETag: 'test' }) - - // Fail the part 7 upload once, then let it succeed - let calls = 0 - scope - .put( - (uri) => - uri.includes('test/upload/multitest.dat') && - uri.includes('partNumber=7'), - ) - .reply(() => (calls++ === 0 ? [500] : [200, '', { ETag: 'test' }])) - - scope.persist() - - // Spy on the busy/done state of the test chunk (part 7, chunk index 6) - let busySpy: Mock - let doneSpy: Mock - awsS3Multipart.setOptions({ - shouldUseMultipart: true, - retryDelays: [10], - createMultipartUpload: vi.fn((file) => { - // @ts-expect-error protected property - const multipartUploader = awsS3Multipart.uploaders[file.id]! - const testChunkState = - // @ts-expect-error private method - multipartUploader[Symbol.for('uppy test: getChunkState')]()[6] - let busy = false - let done = false - busySpy = vi.fn((value) => { - busy = value - }) - doneSpy = vi.fn((value) => { - done = value - }) - Object.defineProperty(testChunkState, 'busy', { - get: () => busy, - set: busySpy, - }) - Object.defineProperty(testChunkState, 'done', { - get: () => done, - set: doneSpy, - }) - - return { - uploadId: '6aeb1980f3fc7ce0b5454d25b71992', - key: 'test/upload/multitest.dat', - } - }), - }) - - core.addFile({ - source: 'vi', - name: 'multitest.dat', - type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], '', { - type: 'application/octet-stream', - }), - }) - - await core.upload() - - // The chunk should be marked as done once - expect(doneSpy!.mock.calls.length).toEqual(1) - expect(doneSpy!.mock.calls[0][0]).toEqual(true) - - // Any changes that set busy to false should only happen after the chunk has been marked done, - // otherwise a race condition occurs (see PR #3955) - const doneCallOrderNumber = doneSpy!.mock.invocationCallOrder[0] - for (const [index, callArgs] of busySpy!.mock.calls.entries()) { - if (callArgs[0] === false) { - expect(busySpy!.mock.invocationCallOrder[index]).toBeGreaterThan( - doneCallOrderNumber, - ) - } - } - - expect((awsS3Multipart.opts as any).signPart.mock.calls.length).toEqual( - 10, - ) - }) - }) - - describe('MultipartUploader', () => { - const createMultipartUpload = vi.fn(() => { - return { - uploadId: '6aeb1980f3fc7ce0b5454d25b71992', - key: 'test/upload/multitest.dat', - } - }) - - const signPart = vi.fn(async (file, { partNumber }) => { - return { - url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/${file.name}?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`, - } - }) - - const uploadPartBytes = vi.fn() - - afterEach(() => { - vi.clearAllMocks() - }) - - it('retries uploadPartBytes when it fails once', async () => { - const core = new Core().use(AwsS3Multipart, { - shouldUseMultipart: true, - createMultipartUpload, - completeMultipartUpload: vi.fn(async () => ({ location: 'test' })), - abortMultipartUpload: vi.fn(() => { - throw 'should ignore' - }), - signPart, - uploadPartBytes: uploadPartBytes.mockImplementationOnce(() => - Promise.reject({ source: { status: 500 } }), - ), - listParts: undefined as any, - }) - const fileSize = 5 * MB + 1 * MB - - core.addFile({ - source: 'vi', - name: 'multitest.dat', - type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], '', { - type: 'application/octet-stream', - }), - }) - - await core.upload() - - expect(uploadPartBytes.mock.calls.length).toEqual(3) - }) - - it('calls `upload-error` when uploadPartBytes fails after all retries', async () => { - const core = new Core().use(AwsS3Multipart, { - shouldUseMultipart: true, - retryDelays: [10], - createMultipartUpload: vi.fn((file) => ({ - uploadId: '6aeb1980f3fc7ce0b5454d25b71992', - key: `test/upload/${file.name}`, - })), - completeMultipartUpload: vi.fn(async () => ({ location: 'test' })), - abortMultipartUpload: vi.fn(), - signPart, - uploadPartBytes: uploadPartBytes.mockImplementation((options) => { - if (options.signature.url.includes('succeed.dat')) { - return new Promise((resolve) => { - // delay until after multitest.dat has failed. - setTimeout(() => resolve({ status: 200 }), 100) - }) - } - return Promise.reject({ source: { status: 500 } }) - }), - listParts: undefined as any, - }) - const fileSize = 5 * MB + 1 * MB - const uploadErrorMock = vi.fn() - const uploadSuccessMock = vi.fn() - core.on('upload-error', uploadErrorMock) - core.on('upload-success', uploadSuccessMock) - - core.addFile({ - source: 'vi', - name: 'fail.dat', - type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], '', { - type: 'application/octet-stream', - }), - }) - - core.addFile({ - source: 'vi', - name: 'succeed.dat', - type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], '', { - type: 'application/octet-stream', - }), - }) - - try { - const results = await core.upload() - expect(results!.successful!.length).toEqual(1) - expect(results!.failed!.length).toEqual(1) - } catch { - // Catch Promise.all reject - } - - expect(uploadPartBytes.mock.calls.length).toEqual(5) - expect(uploadErrorMock.mock.calls.length).toEqual(1) - expect(uploadSuccessMock.mock.calls.length).toEqual(1) // This fails for me becuase upload returned early. - }) - - it('retries signPart when it fails', async () => { - // The retry logic for signPart happens in the uploadChunk method of HTTPCommunicationQueue - // For a 6MB file, we expect 2 parts, so signPart should be called for each part - let callCount = 0 - const signPartWithRetry = vi.fn((file, { partNumber }) => { - callCount++ - if (callCount === 1) { - // First call fails with a retryable error - throw { source: { status: 500 } } - } - return { - url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992`, - } - }) - - const core = new Core().use(AwsS3Multipart, { - shouldUseMultipart: true, - retryDelays: [10], - createMultipartUpload: vi.fn(() => ({ - uploadId: '6aeb1980f3fc7ce0b5454d25b71992', - key: 'test/upload/multitest.dat', - })), - completeMultipartUpload: vi.fn(async () => ({ location: 'test' })), - abortMultipartUpload: vi.fn(), - signPart: signPartWithRetry, - uploadPartBytes: vi.fn().mockResolvedValue({ status: 200 }), - listParts: undefined as any, - }) - const fileSize = 5 * MB + 1 * MB - - core.addFile({ - source: 'vi', - name: 'multitest.dat', - type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], '', { - type: 'application/octet-stream', - }), - }) - - await core.upload() - - // Should be called 3 times: 1 failed + 1 retry + 1 for second part - expect(signPartWithRetry).toHaveBeenCalledTimes(3) - }) - }) - - describe('dynamic companionHeader', () => { - let core: Core - let awsS3Multipart: AwsS3Multipart - const oldToken = 'old token' - const newToken = 'new token' - - beforeEach(() => { - core = new Core() - core.use(AwsS3Multipart, { - endpoint: '', - headers: { - authorization: oldToken, - }, - }) - awsS3Multipart = core.getPlugin('AwsS3Multipart') as any - }) - - it('companionHeader is updated before uploading file', async () => { - awsS3Multipart.setOptions({ - endpoint: 'http://localhost', - headers: { - authorization: newToken, - }, - }) - - await core.upload() - - // @ts-expect-error private property - const client = awsS3Multipart[Symbol.for('uppy test: getClient')]() - - expect( - client[Symbol.for('uppy test: getCompanionHeaders')]().authorization, - ).toEqual(newToken) - }) - }) - - describe('dynamic companionHeader using setOption', () => { - let core: Core - let awsS3Multipart: AwsS3Multipart - const newToken = 'new token' - - it('companionHeader is updated before uploading file', async () => { - core = new Core() - core.use(AwsS3Multipart) - /* Set up preprocessor */ - core.addPreProcessor(() => { - awsS3Multipart = - core.getPlugin>('AwsS3Multipart')! - awsS3Multipart.setOptions({ - endpoint: 'http://localhost', - headers: { - authorization: newToken, - }, - }) - }) - - await core.upload() - - // @ts-expect-error private property - const client = awsS3Multipart[Symbol.for('uppy test: getClient')]() - - expect( - client[Symbol.for('uppy test: getCompanionHeaders')]().authorization, - ).toEqual(newToken) - }) - }) - - describe('file metadata across custom main functions', () => { - let core: Core - const createMultipartUpload = vi.fn((file) => { - core.setFileMeta(file.id, { - ...file.meta, - createMultipartUpload: true, - }) - - return { - uploadId: 'upload1234', - key: file.name, - } - }) - - const signPart = vi.fn((file, partData) => { - expect(file.meta.createMultipartUpload).toBe(true) - - core.setFileMeta(file.id, { - ...file.meta, - signPart: true, - [`part${partData.partNumber}`]: partData.partNumber, - }) - - return { - url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partData.partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`, - } - }) - - const listParts = vi.fn((file) => { - expect(file.meta.createMultipartUpload).toBe(true) - core.setFileMeta(file.id, { - ...file.meta, - listParts: true, - }) - - const partKeys = Object.keys(file.meta).filter((metaKey) => - metaKey.startsWith('part'), - ) - return partKeys.map((metaKey) => ({ - PartNumber: file.meta[metaKey], - ETag: metaKey, - Size: 5 * MB, - })) - }) - - const completeMultipartUpload = vi.fn((file) => { - expect(file.meta.createMultipartUpload).toBe(true) - expect(file.meta.signPart).toBe(true) - for (let i = 1; i <= 10; i++) { - expect(file.meta[`part${i}`]).toBe(i) - } - return {} - }) - - const abortMultipartUpload = vi.fn((file) => { - expect(file.meta.createMultipartUpload).toBe(true) - expect(file.meta.signPart).toBe(true) - expect(file.meta.abortingPart).toBe(5) - }) - - beforeEach(() => { - createMultipartUpload.mockClear() - signPart.mockClear() - listParts.mockClear() - abortMultipartUpload.mockClear() - completeMultipartUpload.mockClear() - }) - - it('preserves file metadata if upload is completed', async () => { - core = new Core().use(AwsS3Multipart, { - shouldUseMultipart: true, - createMultipartUpload, - signPart, - listParts, - completeMultipartUpload, - abortMultipartUpload, - }) - - nock('https://bucket.s3.us-east-2.amazonaws.com') - .defaultReplyHeaders({ - 'access-control-allow-headers': '*', - 'access-control-allow-method': 'PUT', - 'access-control-allow-origin': '*', - 'access-control-expose-headers': 'ETag', - }) - .put((uri) => uri.includes('test/upload/multitest.dat')) - .reply(200, '', { ETag: 'test' }) - .persist() - - const fileSize = 50 * MB - core.addFile({ - source: 'vi', - name: 'multitest.dat', - type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], '', { - type: 'application/octet-stream', - }), - }) - - await core.upload() - expect(createMultipartUpload).toHaveBeenCalled() - expect(signPart).toHaveBeenCalledTimes(11) - expect(completeMultipartUpload).toHaveBeenCalled() - }) - - it('preserves file metadata if upload is aborted', async () => { - const signPartWithAbort = vi.fn((file, partData) => { - expect(file.meta.createMultipartUpload).toBe(true) - if (partData.partNumber === 5) { - core.setFileMeta(file.id, { - ...file.meta, - abortingPart: partData.partNumber, - }) - core.removeFile(file.id) - return { - url: undefined as any as string, - } - } - - core.setFileMeta(file.id, { - ...file.meta, - signPart: true, - [`part${partData.partNumber}`]: partData.partNumber, - }) - - return { - url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partData.partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`, - } - }) - - core = new Core().use(AwsS3Multipart, { - shouldUseMultipart: true, - createMultipartUpload, - signPart: signPartWithAbort, - listParts, - completeMultipartUpload, - abortMultipartUpload, - }) - - nock('https://bucket.s3.us-east-2.amazonaws.com') - .defaultReplyHeaders({ - 'access-control-allow-headers': '*', - 'access-control-allow-method': 'PUT', - 'access-control-allow-origin': '*', - 'access-control-expose-headers': 'ETag', - }) - .put((uri) => uri.includes('test/upload/multitest.dat')) - .reply(200, '', { ETag: 'test' }) - .persist() - - const fileSize = 50 * MB - core.addFile({ - source: 'vi', - name: 'multitest.dat', - type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], '', { - type: 'application/octet-stream', - }), - }) - - await core.upload() - expect(createMultipartUpload).toHaveBeenCalled() - expect(signPartWithAbort).toHaveBeenCalled() - expect(abortMultipartUpload).toHaveBeenCalled() - }) - - it('preserves file metadata if upload is paused and resumed', async () => { - const completeMultipartUploadAfterPause = vi.fn((file) => { - expect(file.meta.createMultipartUpload).toBe(true) - expect(file.meta.signPart).toBe(true) - for (let i = 1; i <= 10; i++) { - expect(file.meta[`part${i}`]).toBe(i) - } - - expect(file.meta.listParts).toBe(true) - return {} - }) - - const signPartWithPause = vi.fn((file, partData) => { - expect(file.meta.createMultipartUpload).toBe(true) - if (partData.partNumber === 3) { - core.setFileMeta(file.id, { - ...file.meta, - abortingPart: partData.partNumber, - }) - core.pauseResume(file.id) - setTimeout(() => core.pauseResume(file.id), 500) - } - - core.setFileMeta(file.id, { - ...file.meta, - signPart: true, - [`part${partData.partNumber}`]: partData.partNumber, - }) - - return { - url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partData.partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`, - } - }) - - core = new Core().use(AwsS3Multipart, { - shouldUseMultipart: true, - createMultipartUpload, - signPart: signPartWithPause, - listParts, - completeMultipartUpload: completeMultipartUploadAfterPause, - abortMultipartUpload, - }) - - nock('https://bucket.s3.us-east-2.amazonaws.com') - .defaultReplyHeaders({ - 'access-control-allow-headers': '*', - 'access-control-allow-method': 'PUT', - 'access-control-allow-origin': '*', - 'access-control-expose-headers': 'ETag', - }) - .put((uri) => uri.includes('test/upload/multitest.dat')) - .reply(200, '', { ETag: 'test' }) - .persist() - - const fileSize = 50 * MB - core.addFile({ - source: 'vi', - name: 'multitest.dat', - type: 'application/octet-stream', - data: new File([new Uint8Array(fileSize)], '', { - type: 'application/octet-stream', - }), - }) - - await core.upload() - expect(createMultipartUpload).toHaveBeenCalled() - expect(signPartWithPause).toHaveBeenCalled() - expect(listParts).toHaveBeenCalled() - expect(completeMultipartUploadAfterPause).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/@uppy/aws-s3/src/index.ts b/packages/@uppy/aws-s3/src/index.ts index d248f4c5be..7f0dbd511d 100644 --- a/packages/@uppy/aws-s3/src/index.ts +++ b/packages/@uppy/aws-s3/src/index.ts @@ -1,8 +1,7 @@ -import { RequestClient } from '@uppy/companion-client' +import type { RequestClient } from '@uppy/companion-client' import { BasePlugin, type DefinePluginOpts, - EventManager, type PluginOpts, type Uppy, } from '@uppy/core' @@ -10,30 +9,31 @@ import type { Body, LocalUppyFile, Meta, - RequestOptions, + RemoteUppyFile, UppyFile, } from '@uppy/utils' import { - createAbortError, filterFilesToEmitUploadStarted, filterFilesToUpload, getAllowedMetaFields, - RateLimitedQueue, + TaskQueue, } from '@uppy/utils' import packageJson from '../package.json' with { type: 'json' } -import createSignedURL from './createSignedURL.js' -import { HTTPCommunicationQueue } from './HTTPCommunicationQueue.js' -import MultipartUploader from './MultipartUploader.js' -import type { - MultipartUploadResultWithSignal, - UploadPartBytesResult, - UploadResult, - UploadResultWithSignal, -} from './utils.js' -import { throwIfAborted } from './utils.js' +import S3Uploader, { type UploadResult } from './S3Uploader.js' +import S3Companion from './s3-client/CompanionS3.js' +import type S3Client from './s3-client/S3Client.js' +import S3mini from './s3-client/S3mini.js' +import type * as IT from './s3-client/types.js' + +// ============================================================================ +// Types +// ============================================================================ -type MultipartFile = UppyFile & { - s3Multipart: UploadResult +/** Part information for multipart uploads */ +export interface AwsS3Part { + PartNumber?: number + Size?: number + ETag?: string } type PartUploadedCallback = ( @@ -45,983 +45,352 @@ declare module '@uppy/core' { export interface UppyEventMap { 's3-multipart:part-uploaded': PartUploadedCallback } - export interface PluginTypeRegistry { - AwsS3Multipart: AwsS3Multipart - } -} - -function assertServerError(res: T): T { - if ((res as any)?.error) { - const error = new Error((res as any).message) - Object.assign(error, (res as any).error) - throw error - } - return res -} - -export interface AwsS3STSResponse { - credentials: { - AccessKeyId: string - SecretAccessKey: string - SessionToken: string - Expiration?: string - } - bucket: string - region: string -} - -/** - * Computes the expiry time for a request signed with temporary credentials. If - * no expiration was provided, or an invalid value (e.g. in the past) is - * provided, undefined is returned. This function assumes the client clock is in - * sync with the remote server, which is a requirement for the signature to be - * validated for AWS anyway. - */ -function getExpiry( - credentials: AwsS3STSResponse['credentials'], -): number | undefined { - const expirationDate = credentials.Expiration - if (expirationDate) { - const timeUntilExpiry = Math.floor( - ((new Date(expirationDate) as any as number) - Date.now()) / 1000, - ) - if (timeUntilExpiry > 9) { - return timeUntilExpiry - } - } - return undefined -} - -function getAllowedMetadata>({ - meta, - allowedMetaFields, - querify = false, -}: { - meta: M - allowedMetaFields?: string[] | null - querify?: boolean -}) { - const metaFields = allowedMetaFields ?? Object.keys(meta) - - if (!meta) return {} - - return Object.fromEntries( - metaFields - .filter((key) => meta[key] != null) - .map((key) => { - const realKey = querify ? `metadata[${key}]` : key - const value = String(meta[key]) - return [realKey, value] - }), - ) -} - -type MaybePromise = T | Promise - -type SignPartOptions = { - uploadId: string - key: string - partNumber: number - body: Blob - signal?: AbortSignal -} - -export type AwsS3UploadParameters = - | { - method: 'POST' - url: string - fields: Record - expires?: number - headers?: Record - } - | { - method?: 'PUT' - url: string - fields?: Record - expires?: number - headers?: Record - } - -export interface AwsS3Part { - PartNumber?: number - Size?: number - ETag?: string } -type AWSS3WithCompanion = { - endpoint: ConstructorParameters< - typeof RequestClient - >[1]['companionUrl'] - headers?: ConstructorParameters< - typeof RequestClient - >[1]['companionHeaders'] - cookiesRule?: ConstructorParameters< - typeof RequestClient - >[1]['companionCookiesRule'] - getTemporarySecurityCredentials?: true -} -type AWSS3WithoutCompanion = { - getTemporarySecurityCredentials?: (options?: { - signal?: AbortSignal - }) => MaybePromise - uploadPartBytes?: (options: { - signature: AwsS3UploadParameters - body: FormData | Blob - size?: number - onProgress: any - onComplete: any - signal?: AbortSignal - }) => Promise -} - -// biome-ignore lint/complexity/noBannedTypes: ... -type AWSS3NonMultipartWithCompanionMandatory = { - // No related options -} - -type AWSS3NonMultipartWithoutCompanionMandatory< - M extends Meta, - B extends Body, -> = { - getUploadParameters: ( - file: UppyFile, - options: RequestOptions, - ) => MaybePromise -} -type AWSS3NonMultipartWithCompanion = AWSS3WithCompanion & - AWSS3NonMultipartWithCompanionMandatory & { - shouldUseMultipart: false - } - -type AWSS3NonMultipartWithoutCompanion< - M extends Meta, - B extends Body, -> = AWSS3WithoutCompanion & - AWSS3NonMultipartWithoutCompanionMandatory & { - shouldUseMultipart: false - } - -type AWSS3MultipartWithoutCompanionMandatorySignPart< - M extends Meta, - B extends Body, -> = { - signPart: ( - file: UppyFile, - opts: SignPartOptions, - ) => MaybePromise -} -type AWSS3MultipartWithoutCompanionMandatory = { +export type AwsS3Options = PluginOpts & { + /** + * Whether to use multipart uploads. + * - `true`: Always use multipart + * - `false`: Always use simple PUT + * - `function`: Called with file, return true for multipart + * Default: Use multipart for files > 100MB + */ + shouldUseMultipart?: boolean | ((file: UppyFile) => boolean) getChunkSize?: (file: { size: number }) => number - createMultipartUpload: (file: UppyFile) => MaybePromise - listParts: ( - file: UppyFile, - opts: UploadResultWithSignal, - ) => MaybePromise - abortMultipartUpload: ( - file: UppyFile, - opts: UploadResultWithSignal, - ) => MaybePromise - completeMultipartUpload: ( - file: UppyFile, - opts: { - uploadId: string - key: string - parts: AwsS3Part[] - signal: AbortSignal - }, - ) => MaybePromise<{ location?: string }> -} & AWSS3MultipartWithoutCompanionMandatorySignPart - -type AWSS3MultipartWithoutCompanion< - M extends Meta, - B extends Body, -> = AWSS3WithoutCompanion & - AWSS3MultipartWithoutCompanionMandatory & { - shouldUseMultipart?: true - } - -type AWSS3MultipartWithCompanion< - M extends Meta, - B extends Body, -> = AWSS3WithCompanion & - Partial> & { - shouldUseMultipart?: true - } - -type AWSS3MaybeMultipartWithCompanion< - M extends Meta, - B extends Body, -> = AWSS3WithCompanion & - Partial> & - AWSS3NonMultipartWithCompanionMandatory & { - shouldUseMultipart: (file: UppyFile) => boolean - } - -type AWSS3MaybeMultipartWithoutCompanion< - M extends Meta, - B extends Body, -> = AWSS3WithoutCompanion & - AWSS3MultipartWithoutCompanionMandatory & - AWSS3NonMultipartWithoutCompanionMandatory & { - shouldUseMultipart: (file: UppyFile) => boolean - } - -interface _AwsS3MultipartOptions extends PluginOpts { allowedMetaFields?: string[] | boolean + + /** + * Maximum number of files uploading concurrently. + * Each file uploads its parts sequentially. + * + * Default: 6 — chosen to match the browser's HTTP/1.1 per-origin connection + * limit. Most browsers allow 6 concurrent connections per host, so this + * prevents queueing at the browser level while maximizing throughput. + */ limit?: number - retryDelays?: number[] | null -} -export type AwsS3MultipartOptions< - M extends Meta, - B extends Body, -> = _AwsS3MultipartOptions & - ( - | AWSS3NonMultipartWithCompanion - | AWSS3NonMultipartWithoutCompanion - | AWSS3MultipartWithCompanion - | AWSS3MultipartWithoutCompanion - | AWSS3MaybeMultipartWithCompanion - | AWSS3MaybeMultipartWithoutCompanion + /** + * Custom function to generate the S3 object key. + * Default: `{randomId}-{filename}` + */ + generateObjectKey?: (file: UppyFile) => string +} & ( + | { + /** S3 upload endpoint */ + s3Endpoint: string + + /** AWS region, required for signing */ + region?: string + + /** + * Function to retrieve temporary credentials for client-side signing. + * When provided, S3mini handles signing internally using SigV4. + * Alternative to signRequest or endpoint. + */ + getCredentials: IT.GetCredentialsFn + } + | { + /** + * Custom function to sign requests. + * Called with request details, should return signed headers. + * Alternative to using Companion endpoint. + */ + signRequest: IT.SignRequestFn + } + | { + /** Companion URL if you want to use Companion for signing */ + companionEndpoint: string + } ) -export type { AwsS3MultipartOptions as AwsS3Options } +// ============================================================================ +// Constants +// ============================================================================ + +const MB = 1024 * 1024 const defaultOptions = { + shouldUseMultipart: (file: UppyFile) => (file.size || 0) > 100 * MB, allowedMetaFields: true, + // 6 matches browser HTTP/1.1 per-origin connection limit limit: 6, - getTemporarySecurityCredentials: false as any, - shouldUseMultipart: ((file: UppyFile) => - (file.size || 0) > 100 * 1024 * 1024) as any as true, - retryDelays: [0, 1000, 3000, 5000], -} satisfies Partial> +} satisfies Partial> -export type { AwsBody } from './utils.js' +// ============================================================================ +// S3Uploader Types +// ============================================================================ -export default class AwsS3Multipart< - M extends Meta, - B extends Body, -> extends BasePlugin< - DefinePluginOpts, keyof typeof defaultOptions> & - // We also have a few dynamic options defined below: - Pick< - AWSS3MultipartWithoutCompanionMandatory, - | 'getChunkSize' - | 'createMultipartUpload' - | 'listParts' - | 'abortMultipartUpload' - | 'completeMultipartUpload' - > & - Required> & - Partial & - AWSS3MultipartWithoutCompanionMandatorySignPart & - AWSS3NonMultipartWithoutCompanionMandatory, +export default class AwsS3 extends BasePlugin< + DefinePluginOpts, keyof typeof defaultOptions>, M, B > { static VERSION = packageJson.version - #companionCommunicationQueue - - #client!: RequestClient - - protected requests: any + #s3Client!: S3Client + #queue!: TaskQueue + #uploaders: Record | null> = {} - protected uploaderEvents: Record | null> - - protected uploaders: Record | null> - - constructor(uppy: Uppy, opts?: AwsS3MultipartOptions) { - super(uppy, { - ...defaultOptions, - uploadPartBytes: AwsS3Multipart.uploadPartBytes, - createMultipartUpload: null as any, - listParts: null as any, - abortMultipartUpload: null as any, - completeMultipartUpload: null as any, - signPart: null as any, - getUploadParameters: null as any, - ...opts, - }) - // We need the `as any` here because of the dynamic default options. + constructor(uppy: Uppy, opts: AwsS3Options) { + super(uppy, { ...defaultOptions, ...opts }) this.type = 'uploader' - this.id = this.opts.id || 'AwsS3Multipart' - this.#setClient(opts) - - const dynamicDefaultOptions = { - createMultipartUpload: this.createMultipartUpload, - listParts: this.listParts, - abortMultipartUpload: this.abortMultipartUpload, - completeMultipartUpload: this.completeMultipartUpload, - signPart: opts?.getTemporarySecurityCredentials - ? this.createSignedURL - : this.signPart, - getUploadParameters: opts?.getTemporarySecurityCredentials - ? (this.createSignedURL as any) - : this.getUploadParameters, - } satisfies Partial> - - for (const key of Object.keys(dynamicDefaultOptions)) { - if (this.opts[key as keyof typeof dynamicDefaultOptions] == null) { - this.opts[key as keyof typeof dynamicDefaultOptions] = - dynamicDefaultOptions[key as keyof typeof dynamicDefaultOptions].bind( - this, - ) - } - } - - /** - * Simultaneous upload limiting is shared across all uploads with this plugin. - * - * @type {RateLimitedQueue} - */ - this.requests = - (this.opts as any).rateLimitedQueue ?? - new RateLimitedQueue(this.opts.limit) - this.#companionCommunicationQueue = new HTTPCommunicationQueue( - this.requests, - this.opts, - this.#setS3MultipartState, - this.#getFile, - ) - - this.uploaders = Object.create(null) - this.uploaderEvents = Object.create(null) - } - - private [Symbol.for('uppy test: getClient')]() { - return this.#client - } - - #setClient(opts?: Partial>) { - if ( - opts == null || - !( - 'endpoint' in opts || - 'companionUrl' in opts || - 'headers' in opts || - 'companionHeaders' in opts || - 'cookiesRule' in opts || - 'companionCookiesRule' in opts - ) - ) - return - if ('companionUrl' in opts && !('endpoint' in opts)) { - this.uppy.log( - '`companionUrl` option has been removed in @uppy/aws-s3, use `endpoint` instead.', - 'warning', - ) - } - if ('companionHeaders' in opts && !('headers' in opts)) { - this.uppy.log( - '`companionHeaders` option has been removed in @uppy/aws-s3, use `headers` instead.', - 'warning', - ) - } - if ('companionCookiesRule' in opts && !('cookiesRule' in opts)) { - this.uppy.log( - '`companionCookiesRule` option has been removed in @uppy/aws-s3, use `cookiesRule` instead.', - 'warning', - ) - } - if ('endpoint' in opts) { - this.#client = new RequestClient(this.uppy, { - pluginId: this.id, - provider: 'AWS', - companionUrl: this.opts.endpoint!, - companionHeaders: this.opts.headers, - companionCookiesRule: this.opts.cookiesRule, - }) - } else { - if ('headers' in opts) { - this.#setCompanionHeaders() - } - if ('cookiesRule' in opts) { - this.#client.opts.companionCookiesRule = opts.cookiesRule - } - } - } - - setOptions(newOptions: Partial>): void { - this.#companionCommunicationQueue.setOptions(newOptions) - super.setOptions(newOptions as any) - this.#setClient(newOptions) - } - - /** - * Clean up all references for a file's upload: the MultipartUploader instance, - * any events related to the file, and the Companion WebSocket connection. - * - * Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed. - * This should be done when the user cancels the upload, not when the upload is completed or errored. - */ - resetUploaderReferences(fileID: string, opts?: { abort: boolean }): void { - if (this.uploaders[fileID]) { - this.uploaders[fileID]!.abort({ really: opts?.abort || false }) - this.uploaders[fileID] = null - } - if (this.uploaderEvents[fileID]) { - this.uploaderEvents[fileID]!.remove() - this.uploaderEvents[fileID] = null - } + this.id = this.opts.id || 'AwsS3' } - #assertHost(method: string): void { - if (!this.#client) { - throw new Error( - `Expected a \`endpoint\` option containing a URL, or if you are not using Companion, a custom \`${method}\` implementation.`, - ) - } - } - - createMultipartUpload( - file: UppyFile, - signal?: AbortSignal, - ): Promise { - this.#assertHost('createMultipartUpload') - throwIfAborted(signal) - - const allowedMetaFields = getAllowedMetaFields( - this.opts.allowedMetaFields, - file.meta, - ) - const metadata = getAllowedMetadata({ meta: file.meta, allowedMetaFields }) - - return this.#client - .post( - 's3/multipart', - { - filename: file.name, - type: file.type, - metadata, - }, - { signal }, - ) - .then(assertServerError) - } - - listParts( - file: UppyFile, - { key, uploadId, signal }: UploadResultWithSignal, - oldSignal?: AbortSignal, - ): Promise { - signal ??= oldSignal - this.#assertHost('listParts') - throwIfAborted(signal) - - const filename = encodeURIComponent(key) - return this.#client - .get( - `s3/multipart/${encodeURIComponent(uploadId!)}?key=${filename}`, - { signal }, - ) - .then(assertServerError) - } - - completeMultipartUpload( - file: UppyFile, - { key, uploadId, parts, signal }: MultipartUploadResultWithSignal, - oldSignal?: AbortSignal, - ): Promise { - signal ??= oldSignal - this.#assertHost('completeMultipartUpload') - throwIfAborted(signal) - - const filename = encodeURIComponent(key) - const uploadIdEnc = encodeURIComponent(uploadId!) - return this.#client - .post( - `s3/multipart/${uploadIdEnc}/complete?key=${filename}`, - { parts: parts.map(({ ETag, PartNumber }) => ({ ETag, PartNumber })) }, - { signal }, - ) - .then(assertServerError) + install(): void { + this.#setResumableUploadsCapability(true) + this.#initS3Client() + this.#queue = new TaskQueue({ concurrency: this.opts.limit }) + this.uppy.addUploader(this.#upload) + this.uppy.on('cancel-all', this.#handleCancelAll) } - #cachedTemporaryCredentials?: MaybePromise - - async #getTemporarySecurityCredentials(options?: RequestOptions) { - throwIfAborted(options?.signal) - - if (this.#cachedTemporaryCredentials == null) { - const { getTemporarySecurityCredentials } = this.opts - // We do not await it just yet, so concurrent calls do not try to override it: - if (getTemporarySecurityCredentials === true) { - this.#assertHost('getTemporarySecurityCredentials') - this.#cachedTemporaryCredentials = this.#client - .get('s3/sts', options) - .then(assertServerError) - } else { - this.#cachedTemporaryCredentials = - (getTemporarySecurityCredentials as AWSS3WithoutCompanion['getTemporarySecurityCredentials'])!( - options, - ) + uninstall(): void { + this.#setResumableUploadsCapability(false) + this.uppy.removeUploader(this.#upload) + this.uppy.off('cancel-all', this.#handleCancelAll) + this.#queue.clear() + // Abort and clean up any in-flight uploads + for (const fileId of Object.keys(this.#uploaders)) { + const uploader = this.#uploaders[fileId] + if (uploader) { + uploader.abort() } - this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials - setTimeout( - () => { - // At half the time left before expiration, we clear the cache. That's - // an arbitrary tradeoff to limit the number of requests made to the - // remote while limiting the risk of using an expired token in case the - // clocks are not exactly synced. - // The HTTP cache should be configured to ensure a client doesn't request - // more tokens than it needs, but this timeout provides a second layer of - // security in case the HTTP cache is disabled or misconfigured. - this.#cachedTemporaryCredentials = null as any - }, - (getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500, - ) } - - return this.#cachedTemporaryCredentials } - async createSignedURL( - file: UppyFile, - options: SignPartOptions, - ): Promise { - const data = await this.#getTemporarySecurityCredentials(options) - const expires = getExpiry(data.credentials) || 604_800 // 604 800 is the max value accepted by AWS. - - const { uploadId, key, partNumber } = options - - // Return an object in the correct shape. - return { - method: 'PUT', - expires, - fields: {}, - url: `${await createSignedURL({ - accountKey: data.credentials.AccessKeyId, - accountSecret: data.credentials.SecretAccessKey, - sessionToken: data.credentials.SessionToken, - expires, - bucketName: data.bucket, - Region: data.region, - Key: key ?? `${crypto.randomUUID()}-${file.name}`, - uploadId, - partNumber, - })}`, - // Provide content type header required by S3 - headers: { - 'Content-Type': file.type as string, + #setResumableUploadsCapability = (value: boolean): void => { + const { capabilities } = this.uppy.getState() + this.uppy.setState({ + capabilities: { + ...capabilities, + resumableUploads: value, }, - } - } - - signPart( - file: UppyFile, - { uploadId, key, partNumber, signal }: SignPartOptions, - ): Promise { - this.#assertHost('signPart') - throwIfAborted(signal) - - if (uploadId == null || key == null || partNumber == null) { - throw new Error( - 'Cannot sign without a key, an uploadId, and a partNumber', - ) - } - - const filename = encodeURIComponent(key) - return this.#client - .get( - `s3/multipart/${encodeURIComponent(uploadId)}/${partNumber}?key=${filename}`, - { signal }, - ) - .then(assertServerError) - } - - abortMultipartUpload( - file: UppyFile, - { key, uploadId, signal }: UploadResultWithSignal, - ): Promise { - this.#assertHost('abortMultipartUpload') - - const filename = encodeURIComponent(key) - const uploadIdEnc = encodeURIComponent(uploadId!) - return this.#client - .delete(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, { - signal, - }) - .then(assertServerError) - } - - getUploadParameters( - file: UppyFile, - options: RequestOptions, - ): Promise { - this.#assertHost('getUploadParameters') - const { meta } = file - const { type, name: filename } = meta - const allowedMetaFields = getAllowedMetaFields( - this.opts.allowedMetaFields, - file.meta, - ) - const metadata = getAllowedMetadata({ - meta, - allowedMetaFields, - querify: true, }) - - const query = new URLSearchParams({ filename, type, ...metadata } as Record< - string, - string - >) - - return this.#client.get(`s3/params?${query}`, options) } - static async uploadPartBytes({ - signature: { url, expires, headers, method = 'PUT' }, - body, - size = (body as Blob).size, - onProgress, - onComplete, - signal, - }: { - signature: AwsS3UploadParameters - body: FormData | Blob - size?: number - onProgress: any - onComplete: any - signal?: AbortSignal - }): Promise { - throwIfAborted(signal) + #handleCancelAll = (): void => { + this.#setResumableUploadsCapability(true) + this.#queue.clear() + } - if (url == null) { - throw new Error('Cannot upload to an undefined URL') - } + // -------------------------------------------------------------------------- + // S3 Client Initialization + // -------------------------------------------------------------------------- - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open(method, url, true) - if (headers) { - Object.keys(headers).forEach((key) => { - xhr.setRequestHeader(key, headers[key]) - }) + #initS3Client(): void { + if ('companionEndpoint' in this.opts) { + if (typeof this.opts.companionEndpoint !== 'string') { + throw new TypeError('companionEndpoint must be a string') } - xhr.responseType = 'text' - if (typeof expires === 'number') { - xhr.timeout = expires * 1000 + this.#s3Client = new S3Companion({ + companionEndpoint: this.opts.companionEndpoint, + }) + } else if ('getCredentials' in this.opts) { + if (typeof this.opts.s3Endpoint !== 'string') { + throw new TypeError('s3Endpoint must be a string') } - - function onabort() { - xhr.abort() + if (typeof this.opts.getCredentials !== 'function') { + throw new TypeError('getCredentials must be a function') } - function cleanup() { - signal?.removeEventListener('abort', onabort) + if (this.opts.region != null && typeof this.opts.region !== 'string') { + throw new TypeError('region must be a string') } - signal?.addEventListener('abort', onabort) - xhr.upload.addEventListener('progress', (ev) => { - onProgress(ev) + // Mode: Temporary credentials (client-side signing) + this.#s3Client = new S3mini({ + endpoint: this.opts.s3Endpoint, + getCredentials: this.opts.getCredentials, + region: this.opts.region, }) - - xhr.addEventListener('abort', () => { - cleanup() - - reject(createAbortError()) + } else if ('signRequest' in this.opts) { + if (typeof this.opts.signRequest !== 'function') { + throw new TypeError('signRequest must be a function') + } + // Mode: Custom signing function + this.#s3Client = new S3mini({ + signRequest: this.opts.signRequest, }) + } else { + throw new TypeError( + 'One of options `companionEndpoint`, `signRequest`, or `getCredentials` is required', + ) + } + } - xhr.addEventListener('timeout', () => { - cleanup() + // -------------------------------------------------------------------------- + // Upload Entry Point + // -------------------------------------------------------------------------- - const error = new Error('Request has expired') - ;(error as any).source = { status: 403 } - reject(error) - }) - xhr.addEventListener('load', () => { - cleanup() + #upload = async (fileIDs: string[]): Promise => { + if (fileIDs.length === 0) return - if ( - xhr.status === 403 && - xhr.responseText.includes('Request has expired') - ) { - const error = new Error('Request has expired') - ;(error as any).source = xhr - reject(error) - return - } - if (xhr.status < 200 || xhr.status >= 300) { - const error = new Error('Non 2xx') - ;(error as any).source = xhr - reject(error) - return - } - - onProgress?.({ loaded: size, lengthComputable: true }) + const files = this.uppy.getFilesByIds(fileIDs) + const filesToUpload = filterFilesToUpload(files) + const filesToEmit = filterFilesToEmitUploadStarted(filesToUpload) - // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getAllResponseHeaders#examples - const arr = xhr - .getAllResponseHeaders() - .trim() - .split(/[\r\n]+/) - // @ts-expect-error null is allowed to avoid inherited properties - const headersMap: Record = { __proto__: null } - for (const line of arr) { - const parts = line.split(': ') - const header = parts.shift()! - const value = parts.join(': ') - headersMap[header] = value - } - const { etag, location } = headersMap + this.uppy.emit('upload-start', filesToEmit) - // More info bucket settings when this is not present: - // https://github.com/transloadit/uppy/issues/5388#issuecomment-2464885562 - if (method.toUpperCase() === 'POST' && location == null) { - // Not being able to read the Location header is not a fatal error. - console.error( - '@uppy/aws-s3: Could not read the Location header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3/#setting-up-your-s3-bucket', - ) - } - if (etag == null) { - console.error( - '@uppy/aws-s3: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3/#setting-up-your-s3-bucket', - ) + const promises = filesToUpload.map((file) => { + if (file.isRemote) { + // Remote uploads are queued internally by RequestClient.uploadRemoteFile() + // via getQueue(), so no outer queue wrapping is needed here. + return this.#uploadRemoteFile(file) + } + return this.#queue.add(async () => { + // File may have been removed while waiting in the queue. + // Unlike actively uploading files, queued files don't have an S3Uploader + // instance yet, so there's no event listener to catch the removal. + // Re-fetch the file to ensure it still exists before starting upload. + const currentFile = this.uppy.getFile(file.id) + if (!currentFile) { return } - - onComplete?.(etag) - resolve({ - ...headersMap, - ETag: etag, // keep capitalised ETag for backwards compatiblity - }) - }) - - xhr.addEventListener('error', (ev) => { - cleanup() - - const error = new Error('Unknown error') - ;(error as any).source = ev.target - reject(error) + return this.#uploadLocalFile(currentFile as LocalUppyFile) // assume it's still a local file since remote files aren't queued }) - - xhr.send(body) }) - } - - #setS3MultipartState = ( - file: UppyFile, - { key, uploadId }: UploadResult, - ) => { - const cFile = this.uppy.getFile(file.id) - if (cFile == null) { - // file was removed from store - return - } - this.uppy.setFileState(file.id, { - s3Multipart: { - ...(cFile as MultipartFile).s3Multipart, - key, - uploadId, - }, - } as Partial>) - } - - #getFile = (file: UppyFile) => { - return this.uppy.getFile(file.id) || file + await Promise.allSettled(promises) + // After the upload batch is done, restore resumable uploads capability. + // It may have been set to false if there were remote files in this batch. + this.#setResumableUploadsCapability(true) } - #uploadLocalFile(file: LocalUppyFile) { - return new Promise((resolve, reject) => { - const onProgress = (bytesUploaded: number, bytesTotal: number) => { - const latestFile = this.uppy.getFile(file.id) - this.uppy.emit('upload-progress', latestFile, { - uploadStarted: latestFile.progress.uploadStarted ?? 0, - bytesUploaded, - bytesTotal, - }) - } - - const onError = (err: unknown) => { - this.uppy.log(err as Error) - this.uppy.emit('upload-error', file, err as Error) - - this.resetUploaderReferences(file.id) - reject(err) - } - - const onSuccess = (result: B) => { - const uploadResp = { - body: { - ...result, + // -------------------------------------------------------------------------- + // Local File Upload + // -------------------------------------------------------------------------- + + async #uploadLocalFile(file: LocalUppyFile): Promise { + try { + return await new Promise((resolve, reject) => { + // Create uploader (events are wired internally). + // S3Uploader detects resume state from file.s3Multipart internally. + const uploader = new S3Uploader({ + uppy: this.uppy, + s3Client: this.#s3Client, + file, + metadata: this.#getAllowedMeta(file), + key: this.#generateKey(file), + shouldUseMultipart: this.#shouldUseMultipart(file), + getChunkSize: this.opts.getChunkSize, + log: (...args) => this.uppy.log(...args), + + onProgress: (bytesUploaded, bytesTotal) => { + this.uppy.emit('upload-progress', file, { + uploadStarted: file.progress.uploadStarted ?? Date.now(), + bytesUploaded, + bytesTotal, + }) }, - status: 200, - uploadURL: result.location as string, - } - - this.resetUploaderReferences(file.id) - - this.uppy.emit('upload-success', this.#getFile(file), uploadResp) - - if (result.location) { - this.uppy.log(`Download ${file.name} from ${result.location}`) - } - - resolve(undefined) - } - if (file.data == null) throw new Error('File data is empty') - - const upload = new MultipartUploader(file.data, { - // .bind to pass the file object to each handler. - companionComm: this.#companionCommunicationQueue, - - log: (...args: Parameters['log']>) => this.uppy.log(...args), - getChunkSize: this.opts.getChunkSize - ? this.opts.getChunkSize.bind(this) - : undefined, - - onProgress, - onError, - onSuccess, - onPartComplete: (part) => { - this.uppy.emit( - 's3-multipart:part-uploaded', - this.#getFile(file), - part, - ) - }, - - file, - shouldUseMultipart: this.opts.shouldUseMultipart, - - ...(file as MultipartFile).s3Multipart, - }) - - this.uploaders[file.id] = upload - const eventManager = new EventManager(this.uppy) - this.uploaderEvents[file.id] = eventManager + onPartComplete: (part) => { + this.uppy.emit('s3-multipart:part-uploaded', file, part) + }, - eventManager.onFileRemove(file.id, (removed) => { - upload.abort() - this.resetUploaderReferences(file.id, { abort: true }) - resolve(`upload ${removed} was removed`) - }) + onSuccess: (result: UploadResult) => { + this.uppy.emit('upload-success', file, { + status: 200, + body: { + location: result.location, + key: result.key, + } satisfies AwsBody as unknown as B, + uploadURL: result.location, + }) + resolve() + }, - eventManager.onCancelAll(file.id, () => { - upload.abort() - this.resetUploaderReferences(file.id, { abort: true }) - resolve(`upload ${file.id} was canceled`) - }) + onError: (err) => { + this.uppy.emit('upload-error', file, err) + reject(err) + }, - eventManager.onFilePause(file.id, (isPaused) => { - if (isPaused) { - upload.pause() - } else { - upload.start() - } - }) + onAbort: () => { + resolve() // Normal completion, not an error + }, + }) - eventManager.onPauseAll(file.id, () => { - upload.pause() - }) + // Store uploader for external abort if needed + this.#uploaders[file.id] = uploader - eventManager.onResumeAll(file.id, () => { - upload.start() + // Start the upload + uploader.start() }) - - upload.start() - }) + } finally { + // Clean up uploader instance after upload completes or fails + delete this.#uploaders[file.id] + } } - #getCompanionClientArgs(file: UppyFile) { - return { - ...('remote' in file && file.remote?.body), - protocol: 's3-multipart', - size: file.data!.size, - metadata: file.meta, + #shouldUseMultipart(file: UppyFile): boolean { + const { shouldUseMultipart } = this.opts + if (typeof shouldUseMultipart === 'function') { + return shouldUseMultipart(file) + } + if (typeof shouldUseMultipart === 'boolean') { + return shouldUseMultipart } + // Default: multipart for files > 100MB + return (file.size ?? 0) > 100 * MB } - #upload = async (fileIDs: string[]) => { - if (fileIDs.length === 0) return undefined - - const files = this.uppy.getFilesByIds(fileIDs) - const filesFiltered = filterFilesToUpload(files) - const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered) - - this.uppy.emit('upload-start', filesToEmit) - - const promises = filesFiltered.map((file) => { - if (file.isRemote) { - const getQueue = () => this.requests - this.#setResumableUploadsCapability(false) - const controller = new AbortController() - - const removedHandler = (removedFile: UppyFile) => { - if (removedFile.id === file.id) controller.abort() - } - this.uppy.on('file-removed', removedHandler) - - const uploadPromise = this.uppy - .getRequestClientForFile>(file) - .uploadRemoteFile(file, this.#getCompanionClientArgs(file), { - signal: controller.signal, - getQueue, - }) - - this.requests.wrapSyncFunction( - () => { - this.uppy.off('file-removed', removedHandler) - }, - { priority: -1 }, - )() - - return uploadPromise - } - - return this.#uploadLocalFile(file) - }) - - const upload = await Promise.allSettled(promises) - // After the upload is done, another upload may happen with only local files. - // We reset the capability so that the next upload can use resumable uploads. - this.#setResumableUploadsCapability(true) - return upload + #generateKey(file: UppyFile): string { + return this.opts.generateObjectKey?.(file) ?? file.name } - #setCompanionHeaders = () => { - this.#client?.setCompanionHeaders(this.opts.headers!) - } + // -------------------------------------------------------------------------- + // Remote File Upload + // -------------------------------------------------------------------------- - #setResumableUploadsCapability = (boolean: boolean) => { - const { capabilities } = this.uppy.getState() - this.uppy.setState({ - capabilities: { - ...capabilities, - resumableUploads: boolean, - }, - }) + #getAllowedMeta(file: UppyFile) { + const allowedMetaFields = getAllowedMetaFields( + this.opts.allowedMetaFields, + file.meta, + ) + return Object.fromEntries( + allowedMetaFields.map((key) => [key, file.meta[key]]), + ) } - #resetResumableCapability = () => { - this.#setResumableUploadsCapability(true) + /** + * Builds the request body sent to Companion's provider get endpoint. + * Tells Companion to use its server-side S3 upload path. + */ + #getCompanionClientArgs(file: RemoteUppyFile): Record { + return { + ...file.remote.body, + protocol: 's3-multipart', + size: file.data.size, + metadata: this.#getAllowedMeta(file), + } } - install(): void { - this.#setResumableUploadsCapability(true) - this.uppy.addPreProcessor(this.#setCompanionHeaders) - this.uppy.addUploader(this.#upload) - this.uppy.on('cancel-all', this.#resetResumableCapability) - } + async #uploadRemoteFile(file: RemoteUppyFile): Promise { + this.#setResumableUploadsCapability(false) - uninstall(): void { - this.uppy.removePreProcessor(this.#setCompanionHeaders) - this.uppy.removeUploader(this.#upload) - this.uppy.off('cancel-all', this.#resetResumableCapability) + const controller = new AbortController() + + const removedHandler = (removedFile: UppyFile) => { + if (removedFile.id === file.id) controller.abort() + } + this.uppy.on('file-removed', removedHandler) + + try { + await this.uppy + .getRequestClientForFile>(file) + .uploadRemoteFile(file, this.#getCompanionClientArgs(file), { + signal: controller.signal, + getQueue: () => this.#queue, + }) + } finally { + this.uppy.off('file-removed', removedHandler) + } } } -export type uploadPartBytes = (typeof AwsS3Multipart< - any, - any ->)['uploadPartBytes'] +export type { AwsS3Options as AwsS3MultipartOptions } -export type { - MultipartUploadResult, - MultipartUploadResultWithSignal, - UploadPartBytesResult, - UploadResult, - UploadResultWithSignal, -} from './utils.js' +/** Body type for AWS S3 upload responses */ +export interface AwsBody extends Body { + location: string + key: string +} diff --git a/packages/@uppy/aws-s3/src/s3-client/CompanionS3.ts b/packages/@uppy/aws-s3/src/s3-client/CompanionS3.ts new file mode 100644 index 0000000000..749f42f244 --- /dev/null +++ b/packages/@uppy/aws-s3/src/s3-client/CompanionS3.ts @@ -0,0 +1,212 @@ +import * as C from './consts.js' +import S3Client from './S3Client.js' +import type * as IT from './types.js' +import * as U from './utils.js' + +/** + * S3Companion is an S3Client that interacts with a Companion server to perform S3 operations. + */ +class S3Companion extends S3Client { + readonly companionEndpoint: string + + constructor({ + companionEndpoint, + requestAbortTimeout, + }: { companionEndpoint: string; requestAbortTimeout?: number | undefined }) { + super({ requestAbortTimeout }) + this.companionEndpoint = companionEndpoint + } + + private async _fetch(path: string, opts?: RequestInit) { + const response = await fetch(`${this.companionEndpoint}/s3${path}`, opts) + if (!response.ok) { + throw new Error(`Companion request failed: ${response.statusText}`) + } + return response + } + + private async _request({ + url, + method, + data, + onProgress, + signal, + contentType, + }: { + url: string + method: IT.HttpMethod + data?: XMLHttpRequestBodyInit + onProgress?: IT.OnProgressFn + signal?: AbortSignal + contentType?: string + }): Promise { + // Wait for online before starting + await this.waitForOnline(signal) + + return this.xhr({ url, method, data, onProgress, signal, contentType }) + } + + public override async putObject( + keyIn: string, + data: Blob | File, + fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE, + metadata: Record, + onProgress?: IT.OnProgressFn, + signal?: AbortSignal, + ) { + const response = await this._fetch( + `/params?${new URLSearchParams({ filename: keyIn, type: fileType, ...Object.fromEntries(Object.entries(metadata).map(([k, v]) => [`metadata[${k}]`, String(v)])) })}`, + ) + const { url, fields }: { url: string; fields: Record } = + await response.json() + + const formData = new FormData() + Object.entries(fields).forEach(([key, value]) => formData.set(key, value)) + formData.set('file', data) + + const xhr = await this._request({ + url, + method: 'POST', + data: formData, + onProgress, + signal, + }) + + return { + location: `${url}${fields.key}`, // `url` is returned by the signer as the bucket URL without any path, but trailing slash, so we need to add the key (path) to get the full object URL + etag: U.sanitizeETag(xhr.getResponseHeader('etag')), + key: fields.key, + } + } + + public override async createMultipartUpload( + keyIn: string, + fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE, + metadata: Record, + ) { + if (typeof fileType !== 'string') { + throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`) + } + + const method = 'POST' + const response = await this._fetch('/multipart', { + method, + body: JSON.stringify({ filename: keyIn, metadata, type: fileType }), + headers: { 'content-type': 'application/json' }, + }) + const { + key, + uploadId, + }: { key?: string; uploadId?: string; bucket?: string } = + await response.json() + if (uploadId == null) throw new Error('No uploadId returned') + if (key == null) throw new Error('No key returned') + + return { uploadId, key } + } + + public override async uploadPart( + key: string, + uploadId: string, + data: XMLHttpRequestBodyInit, + partNumber: number, + onProgress?: IT.OnProgressFn, + signal?: AbortSignal, + ) { + const response = await this._fetch( + `/multipart/${encodeURIComponent(uploadId)}/${encodeURIComponent(partNumber)}?${new URLSearchParams({ key })}`, + { + method: 'GET', + }, + ) + + const { url }: { url: string } = await response.json() + + const xhr = await this._request({ + url, + method: 'PUT', + data, + onProgress, + signal, + }) + + const etag = U.sanitizeETag(xhr.getResponseHeader('etag')) + if (etag == null) { + throw new Error( + `${C.ERROR_PREFIX}Missing ETag in uploadPart response headers`, + ) + } + + return { etag } + } + + public override async listParts( + uploadId: string, + key: string, + ): Promise { + if (!uploadId) { + throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED) + } + + const response = await this._fetch( + `/multipart/${encodeURIComponent(uploadId)}?${new URLSearchParams({ key })}`, + { + method: 'GET', + }, + ) + + const parts: { PartNumber: string; ETag: string }[] = await response.json() + + return parts.map((p) => ({ + partNumber: parseInt(String(p.PartNumber), 10), + etag: String(p.ETag), + })) + } + + public override async completeMultipartUpload( + key: string, + uploadId: string, + parts: Array, + ) { + const response = await this._fetch( + `/multipart/${encodeURIComponent(uploadId)}/complete?${new URLSearchParams({ key })}`, + { + method: 'POST', + body: JSON.stringify({ + parts: parts.map((part) => ({ + PartNumber: part.partNumber, + ETag: part.etag, + })), + }), + headers: { 'content-type': 'application/json' }, + }, + ) + + const { + location, + bucket, + key: resultKey, + }: { + location: string + bucket?: string + key: string + } = await response.json() + + return { + location, + bucket, + key: resultKey, + } + } + + public override async abortMultipartUpload(key: string, uploadId: string) { + await this._fetch( + `/multipart/${encodeURIComponent(uploadId)}?${new URLSearchParams({ key })}`, + { + method: 'DELETE', + }, + ) + } +} + +export default S3Companion diff --git a/packages/@uppy/aws-s3/src/s3-client/S3Client.ts b/packages/@uppy/aws-s3/src/s3-client/S3Client.ts new file mode 100644 index 0000000000..80c1923a2b --- /dev/null +++ b/packages/@uppy/aws-s3/src/s3-client/S3Client.ts @@ -0,0 +1,197 @@ +import { fetcher } from '@uppy/utils' +import * as C from './consts.js' +import type * as IT from './types.js' + +class S3Client { + readonly requestAbortTimeout?: number + + constructor({ + requestAbortTimeout, + ...rest + }: { requestAbortTimeout?: number | undefined }) { + this.requestAbortTimeout = requestAbortTimeout + } + + /** + * Helper to check if we're currently offline in a browser context. + */ + protected isOffline(): boolean { + return typeof navigator !== 'undefined' && navigator.onLine === false + } + + /** + * Waits for the browser to come back online. + * Returns a promise that resolves when the 'online' event fires, + * or rejects if the abort signal is triggered. + */ + protected waitForOnline(signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (!this.isOffline()) { + resolve() + return + } + // Already online or not in browser + if ( + typeof navigator === 'undefined' || + navigator.onLine === true || + navigator.onLine === undefined + ) { + resolve() + return + } + + // Already aborted + if (signal?.aborted) { + reject(new DOMException('Upload aborted', 'AbortError')) + return + } + + const cleanup = () => { + window.removeEventListener('online', onOnline) + signal?.removeEventListener('abort', onAbort) + } + + const onOnline = () => { + cleanup() + resolve() + } + + const onAbort = () => { + cleanup() + reject(new DOMException('Upload aborted', 'AbortError')) + } + + window.addEventListener('online', onOnline) + signal?.addEventListener('abort', onAbort) + }) + } + + protected async xhr({ + url, + method, + data, + onProgress, + signal, + contentType, + }: { + url: string + method: IT.HttpMethod + data?: XMLHttpRequestBodyInit + onProgress?: IT.OnProgressFn + signal?: AbortSignal + contentType?: string + }) { + // Check if aborted while waiting for online + if (signal?.aborted) { + throw new DOMException('Request aborted', 'AbortError') + } + + return fetcher(url, { + method, + // XHR natively supports ArrayBuffer, Uint8Array, Blob, and string + body: ['GET', 'HEAD'].includes(method) ? undefined : data, + headers: contentType ? { 'Content-Type': contentType } : {}, + signal, + timeout: this.requestAbortTimeout, + retries: 3, + /** + * Retry logic: + * - Retries: 5xx server errors, 429 rate limiting + * - Skips: 4xx client errors (except 429), offline (handled separately) + */ + shouldRetry: (xhr) => { + // If offline, don't retry via fetcher - our handler will resume + if (this.isOffline()) return false + // Don't retry client errors (except 429 rate limit) + if (xhr.status >= 400 && xhr.status < 500 && xhr.status !== 429) { + return false + } + return true + }, + onUploadProgress: (event) => { + if (event.lengthComputable && onProgress) { + onProgress(event.loaded, event.total) + } + }, + onTimeout: (timeout) => { + // Log stall detection - upload will continue but may be slow + console.warn( + `[S3mini] Upload stalled - no progress for ${Math.ceil(timeout / 1000)}s`, + ) + }, + }) + } + + public async putObject( + key: string, + data: XMLHttpRequestBodyInit, + fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE, + metadata: Record, + onProgress?: IT.OnProgressFn, + signal?: AbortSignal, + ): Promise<{ + location: string + key: string + etag: string | undefined + }> { + throw new Error('Not implemented') + } + + public async createMultipartUpload( + key: string, + fileType?: string, + // @ts-expect-error unused + metadata: Record, + ): Promise<{ + uploadId: string + key: string + }> { + throw new Error('Not implemented') + } + + public async uploadPart( + key: string, + uploadId: string, + data: XMLHttpRequestBodyInit, + partNumber: number, + onProgress?: IT.OnProgressFn, + signal?: AbortSignal, + ): Promise<{ + etag: string + }> { + throw new Error('Not implemented') + } + + public async listParts( + uploadId: string, + key: string, + ): Promise { + throw new Error('Not implemented') + } + + public async completeMultipartUpload( + key: string, + uploadId: string, + parts: IT.UploadPart[], + ): Promise<{ + location: string + bucket: string | undefined + key: string + etag?: string | undefined + }> { + throw new Error('Not implemented') + } + + public async abortMultipartUpload( + key: string, + uploadId: string, + ): Promise { + throw new Error('Not implemented') + } + + public async deleteObject(key: string): Promise { + throw new Error('Not implemented') + } +} + +export default S3Client diff --git a/packages/@uppy/aws-s3/src/s3-client/S3mini.ts b/packages/@uppy/aws-s3/src/s3-client/S3mini.ts new file mode 100644 index 0000000000..060be670b8 --- /dev/null +++ b/packages/@uppy/aws-s3/src/s3-client/S3mini.ts @@ -0,0 +1,567 @@ +/** + * Taken from https://github.com/good-lly/s3mini.git, by Jølly Good, under MIT license. + * Modified to make it work with Uppy. + */ + +import * as C from './consts.js' +import S3Client from './S3Client.js' +import { createSigV4Signer } from './signer.js' +import type * as IT from './types.js' +import * as U from './utils.js' + +/** + * S3 client for browser-compatible interaction with S3-compatible storage. + * Supports simple uploads, multipart uploads, and object deletion. + * + * @example + * // Option 1: With signRequest callback (no endpoint needed) + * const s3 = new S3mini({ + * signRequest: async ({ method, key, uploadId, partNumber }) => { + * const resp = await fetch('/api/s3/sign', { + * method: 'POST', + * body: JSON.stringify({ method, key, uploadId, partNumber }), + * }); + * return resp.json(); // { url } + * }, + * }); + * + * // Option 2: With getCredentials callback (endpoint required for client-side signing) + * const s3 = new S3mini({ + * endpoint: 'https://s3.us-east-1.amazonaws.com/my-bucket', + * getCredentials: async () => { + * const resp = await fetch('/api/s3/credentials'); + * return resp.json(); // { credentials, region } + * }, + * }); + * + * await s3.putObject('file.txt', 'Hello, World!'); + */ +class S3mini extends S3Client { + readonly endpoint?: URL + readonly region: string + readonly requestSizeInBytes: number + + private readonly getCredentials?: IT.GetCredentialsFn + private cachedCredentials?: IT.CredentialsResponse + private cachedCredentialsPromise?: Promise + private signRequest!: IT.SignRequestFn + + constructor({ + region = 'auto', + requestSizeInBytes = C.DEFAULT_REQUEST_SIZE_IN_BYTES, + requestAbortTimeout, + ...rest + }: IT.S3Config) { + super({ requestAbortTimeout }) + if ('signRequest' in rest) { + const { signRequest } = rest + if (!signRequest) { + throw new TypeError( + 'Either signRequest or getCredentials must be provided', + ) + } + + if (signRequest && typeof signRequest !== 'function') { + throw new TypeError('signRequest must be a function') + } + + this.signRequest = signRequest + } else if ('getCredentials' in rest) { + const { getCredentials, endpoint } = rest + if (typeof endpoint !== 'string' || endpoint.trim().length === 0) { + throw new TypeError(C.ERROR_ENDPOINT_REQUIRED) + } + if (getCredentials && typeof getCredentials !== 'function') { + throw new TypeError('getCredentials must be a function') + } + this.endpoint = new URL(this._ensureValidUrl(endpoint)) + + this.getCredentials = getCredentials + this.signRequest = this._createCredentialBasedSigner() + } else { + throw new TypeError( + 'Either signRequest or getCredentials must be provided', + ) + } + + this.region = region + this.requestSizeInBytes = requestSizeInBytes + } + + /** Creates a presigner that fetches/caches credentials and generates pre-signed URLs. */ + private _createCredentialBasedSigner(): IT.SignRequestFn { + return async ( + request: IT.PresignableRequest, + ): Promise => { + const creds = await this._getCachedCredentials() + if (this.endpoint == null) { + throw new Error('Endpoint is required for credential-based signing') + } + const presigner = createSigV4Signer({ + accessKeyId: creds.credentials.accessKeyId, + secretAccessKey: creds.credentials.secretAccessKey, + sessionToken: creds.credentials.sessionToken, + region: creds.region || this.region, + endpoint: this.endpoint.toString(), + }) + return presigner(request) + } + } + + /** Gets cached credentials or fetches new ones. */ + private async _getCachedCredentials(): Promise { + // Return Cached Credentials if available + if (this.cachedCredentials != null) { + return this.cachedCredentials + } + + // Cache the promise so concurrent calls wait for the same fetch + if (this.cachedCredentialsPromise == null) { + this.cachedCredentialsPromise = (async () => { + try { + const creds = await this.getCredentials!({}) + this.cachedCredentials = creds + return creds + } finally { + // Clear promise cache after resolution to allow future retries + this.cachedCredentialsPromise = undefined + } + })() + } + + return this.cachedCredentialsPromise + } + + private _ensureValidUrl(raw: string): string { + const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}` + try { + new URL(candidate) + + // Find the last non-slash character + let endIndex = candidate.length + while (endIndex > 0 && candidate[endIndex - 1] === '/') { + endIndex-- + } + return endIndex === candidate.length + ? candidate + : candidate.substring(0, endIndex) + } catch { + const msg = `${C.ERROR_ENDPOINT_FORMAT} But provided: "${raw}"` + throw new TypeError(msg) + } + } + + private _checkKey(key: string): void { + if (typeof key !== 'string' || key.trim().length === 0) { + throw new TypeError(C.ERROR_KEY_REQUIRED) + } + } + + private _validateUploadPartParams( + key: string, + uploadId: string, + partNumber: number, + ): void { + this._checkKey(key) + if (typeof uploadId !== 'string' || uploadId.trim().length === 0) { + throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED) + } + if (!Number.isInteger(partNumber) || partNumber <= 0) { + throw new TypeError( + `${C.ERROR_PREFIX}partNumber must be a positive integer`, + ) + } + } + + /** + * Uploads an object to S3 using XHR for progress tracking. + * @param key - Object key + * @param data - Data to upload (Blob, ArrayBuffer, Uint8Array, or string) + * @param fileType - Content type + * @param onProgress - Optional progress callback + * @param signal - Optional abort signal + */ + public override async putObject( + key: string, + data: XMLHttpRequestBodyInit, + fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE, + metadata?: Record, + onProgress?: IT.OnProgressFn, + signal?: AbortSignal, + ) { + this._checkKey(key) + + const { xhr, url } = await this.request({ + request: { method: 'PUT', key }, + data, + onProgress, + signal, + contentType: fileType, + }) + + return { + location: U.removeQueryString(url), + etag: U.sanitizeETag(xhr.getResponseHeader('etag')), + key, + } + } + + /** Initiates a multipart upload and returns the upload ID. */ + public override async createMultipartUpload( + key: string, + fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE, + metadata?: Record, // todo support metadata here too? + ) { + this._checkKey(key) + if (typeof fileType !== 'string') { + throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`) + } + + const { xhr } = await this.request({ + request: { method: 'POST', key }, + contentType: fileType, + }) + + const parsed = U.parseXml(xhr.responseText) as Record + + if (parsed && typeof parsed === 'object') { + // Check for both cases of InitiateMultipartUploadResult + const uploadResult = + (parsed.initiateMultipartUploadResult as Record) || + (parsed.InitiateMultipartUploadResult as Record) + + if (uploadResult && typeof uploadResult === 'object') { + // Check for both cases of uploadId + const uploadId = uploadResult.uploadId || uploadResult.UploadId + + if (uploadId && typeof uploadId === 'string') { + return { uploadId, key } + } + } + } + + throw new Error( + `${C.ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify( + parsed, + )}`, + ) + } + + public override async uploadPart( + key: string, + uploadId: string, + data: XMLHttpRequestBodyInit, + partNumber: number, + onProgress?: IT.OnProgressFn, + signal?: AbortSignal, + ) { + this._validateUploadPartParams(key, uploadId, partNumber) + + const { xhr } = await this.request({ + request: { + method: 'PUT', + key, + uploadId, + partNumber, + }, + data, + onProgress, + signal, + }) + + const etag = U.sanitizeETag(xhr.getResponseHeader('etag')) + if (etag == null) { + throw new Error( + `${C.ERROR_PREFIX}Missing ETag in uploadPart response headers`, + ) + } + + return { etag } + } + + /** + * Core XHR upload implementation using @uppy/utils/fetcher. + * + * Features: + * - Automatic retry with exponential backoff (3 attempts) + * - Offline detection with automatic resume on reconnect + * - Stall detection via ProgressTimeout + */ + private async request({ + request, + data, + onProgress, + signal, + contentType, + shouldRetryCredentials = true, + }: { + request: IT.PresignableRequest + data?: XMLHttpRequestBodyInit + onProgress?: IT.OnProgressFn + signal?: AbortSignal + contentType?: string + shouldRetryCredentials?: boolean + }): Promise<{ xhr: XMLHttpRequest; url: string }> { + // Wait for online before starting + await this.waitForOnline(signal) + + // Check if aborted while waiting for online + if (signal?.aborted) { + throw new DOMException('Request aborted', 'AbortError') + } + + try { + const { url } = await this.signRequest(request) + + const xhr = await this.xhr({ + url, + method: request.method, + data, + onProgress, + signal, + contentType, + }) + + return { xhr, url } + } catch (err: unknown) { + // NetworkError or errors with attached XHR (from onAfterResponse throws) + if ( + err instanceof Error && + 'request' in err && + err.request instanceof XMLHttpRequest + ) { + const xhr = err.request as XMLHttpRequest + if (xhr.status === 0) { + throw new U.S3NetworkError( + 'Network error during S3 request', + 'NETWORK', + err, + ) + } + // HTTP errors (non-2xx responses from NetworkError) + const parsedBody = this._parseErrorXml( + (name: string) => xhr.getResponseHeader(name), + xhr.responseText, + ) + const serviceCode = + xhr.getResponseHeader('x-amz-error-code') ?? parsedBody.svcCode + + // If expired token error and using getCredentials, clear cache and retry once + if ( + shouldRetryCredentials && + this.getCredentials != null && + serviceCode != null && + ['ExpiredToken', 'InvalidAccessKeyId'].includes(serviceCode) + ) { + this.clearCachedCredentials() + + // Retry with fresh credentials + return this.request({ + request, + data, + onProgress, + signal, + contentType, + shouldRetryCredentials: false, // prevent infinite recursion + }) + } + + throw new U.S3ServiceError( + `S3 returned ${xhr.status}${serviceCode ? ` – ${serviceCode}` : ''}`, + xhr.status, + serviceCode, + xhr.responseText, + ) + } + + throw err + } + } + + /** Lists uploaded parts for a multipart upload. */ + public override async listParts( + uploadId: string, + key: string, + ): Promise { + this._checkKey(key) + if (!uploadId) { + throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED) + } + const { xhr } = await this.request({ + request: { method: 'GET', key, uploadId }, + }) + + const parsed = U.parseXml(xhr.responseText) as Record + const result = (parsed.listPartsResult || + parsed.ListPartsResult || + parsed) as Record + + if (result && typeof result === 'object') { + const parts = result.Part || result.part || [] + const partsArray = Array.isArray(parts) ? parts : [parts] + + return partsArray + .filter( + (p): p is Record => + p != null && + typeof p === 'object' && + 'PartNumber' in p && + 'ETag' in p, + ) + .map((p) => ({ + partNumber: parseInt(String(p.PartNumber), 10), + etag: U.sanitizeXmlETag(String(p.ETag)), + })) + } + return [] + } + + /** Completes a multipart upload by combining all uploaded parts. */ + public override async completeMultipartUpload( + key: string, + uploadId: string, + parts: Array, + ) { + const xmlBody = this._buildCompleteMultipartUploadXml(parts) + + const { xhr } = await this.request({ + request: { method: 'POST', key, uploadId }, + contentType: C.XML_CONTENT_TYPE, + data: xmlBody, + }) + + const parsed = U.parseXml(xhr.responseText) + if (parsed && typeof parsed === 'object') { + // Check for both cases (camelCase from our parser, PascalCase from S3) + const result = + parsed.completeMultipartUploadResult || + parsed.CompleteMultipartUploadResult || + parsed + + if (result && typeof result === 'object') { + const r = result as Record + + // S3 returns PascalCase (Location, Bucket, Key, ETag). + // Normalize to lowercase for our type interface. + const resultLocation = (r.Location || r.location) as string | undefined + const resultBucket = (r.Bucket || r.bucket) as string | undefined + const resultKey = (r.Key || r.key) as string | undefined + const rawEtag = (r.ETag || r.eTag || r.etag) as string | undefined + + if (!resultLocation || !resultKey) { + throw new Error( + `${C.ERROR_PREFIX}CompleteMultipartUpload response missing Location or Key: ${JSON.stringify(r)}`, + ) + } + + const etag = rawEtag ? U.sanitizeXmlETag(rawEtag) : undefined + + return { + location: resultLocation, + bucket: resultBucket, + key: resultKey, + etag, + } + } + } + + throw new Error( + `${C.ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify( + parsed, + )}`, + ) + } + + /** Aborts a multipart upload and removes all uploaded parts. */ + public override async abortMultipartUpload(key: string, uploadId: string) { + this._checkKey(key) + if (!uploadId) { + throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED) + } + + const { xhr } = await this.request({ + request: { method: 'DELETE', key, uploadId }, + }) + + const parsed = U.parseXml(xhr.responseText) as Record + if ( + parsed && + 'error' in parsed && + typeof parsed.error === 'object' && + parsed.error !== null && + 'message' in parsed.error + ) { + throw new Error( + `${C.ERROR_PREFIX}Failed to abort multipart upload: ${String( + parsed.error.message, + )}`, + ) + } + } + + private _buildCompleteMultipartUploadXml( + parts: Array, + ): string { + let xml = '' + for (const part of parts) { + xml += `${part.partNumber}${part.etag}` + } + xml += '' + return xml + } + + /** Deletes an object from the bucket. Returns true on success. */ + public override async deleteObject(key: string) { + const { xhr } = await this.request({ + request: { method: 'DELETE', key }, + }) + + if (xhr.status !== 200 && xhr.status !== 204) { + throw new Error( + `${C.ERROR_PREFIX}Failed to delete object. HTTP status: ${xhr.status}`, + ) + } + } + + /** + * Clears cached credentials. + * Call this method when you need to force a credential refresh on the next request. + */ + public clearCachedCredentials(): void { + this.cachedCredentials = undefined + this.cachedCredentialsPromise = undefined + } + + private _parseErrorXml( + getHeader: (name: string) => string | null, + body: string, + ): { svcCode?: string; errorMessage?: string } { + if (getHeader('content-type') !== 'application/xml') { + return {} + } + const parsedBody = U.parseXml(body) + if ( + !parsedBody || + typeof parsedBody !== 'object' || + !('Error' in parsedBody) || + !parsedBody.Error || + typeof parsedBody.Error !== 'object' + ) { + return {} + } + const error = parsedBody.Error + return { + svcCode: + 'Code' in error && typeof error.Code === 'string' + ? error.Code + : undefined, + errorMessage: + 'Message' in error && typeof error.Message === 'string' + ? error.Message + : undefined, + } + } +} + +export { S3mini } +export default S3mini diff --git a/packages/@uppy/aws-s3/src/s3-client/consts.ts b/packages/@uppy/aws-s3/src/s3-client/consts.ts new file mode 100644 index 0000000000..65c8c55ecf --- /dev/null +++ b/packages/@uppy/aws-s3/src/s3-client/consts.ts @@ -0,0 +1,43 @@ +// Constants +export const AWS_ALGORITHM = 'AWS4-HMAC-SHA256' +export const AWS_REQUEST_TYPE = 'aws4_request' +export const S3_SERVICE = 's3' +export const LIST_TYPE = '2' +export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD' +export const DEFAULT_STREAM_CONTENT_TYPE = 'application/octet-stream' +export const XML_CONTENT_TYPE = 'application/xml' +export const JSON_CONTENT_TYPE = 'application/json' +// List of keys that might contain sensitive information +export const SENSITIVE_KEYS_REDACTED = new Set([ + 'accessKeyId', + 'secretAccessKey', + 'sessionToken', + 'password', + 'token', +] as const) +export const IFHEADERS = new Set([ + 'if-match', + 'if-none-match', + 'if-modified-since', + 'if-unmodified-since', +] as const) +export const DEFAULT_REQUEST_SIZE_IN_BYTES = 8 * 1024 * 1024 + +// Headers +export const HEADER_AMZ_CONTENT_SHA256 = 'x-amz-content-sha256' +export const HEADER_AMZ_CHECKSUM_SHA256 = 'x-amz-checksum-sha256' +export const HEADER_AMZ_DATE = 'x-amz-date' +export const HEADER_HOST = 'host' +export const HEADER_AUTHORIZATION = 'authorization' +export const HEADER_CONTENT_TYPE = 'content-type' +export const HEADER_CONTENT_LENGTH = 'content-length' +export const HEADER_ETAG = 'etag' +export const HEADER_LAST_MODIFIED = 'last-modified' + +// Error messages +export const ERROR_PREFIX = '[s3mini] ' +export const ERROR_ENDPOINT_REQUIRED = `${ERROR_PREFIX}endpoint must be a non-empty string` +export const ERROR_ENDPOINT_FORMAT = `${ERROR_PREFIX}endpoint must be a valid URL. Expected format: https://[:port][/base-path]` +export const ERROR_KEY_REQUIRED = `${ERROR_PREFIX}key must be a non-empty string` +export const ERROR_UPLOAD_ID_REQUIRED = `${ERROR_PREFIX}uploadId must be a non-empty string` +export const ERROR_DATA_BUFFER_REQUIRED = `${ERROR_PREFIX}data must be a Buffer or string` diff --git a/packages/@uppy/aws-s3/src/s3-client/index.ts b/packages/@uppy/aws-s3/src/s3-client/index.ts new file mode 100644 index 0000000000..cf6e14488b --- /dev/null +++ b/packages/@uppy/aws-s3/src/s3-client/index.ts @@ -0,0 +1,8 @@ +// Export the S3 class as default export and named export +export { S3mini } from './S3mini.js' +// Re-export types +export type { + ErrorWithCode, + S3Config, + UploadPart, +} from './types.js' diff --git a/packages/@uppy/aws-s3/src/s3-client/signer.ts b/packages/@uppy/aws-s3/src/s3-client/signer.ts new file mode 100644 index 0000000000..a5c3f58e96 --- /dev/null +++ b/packages/@uppy/aws-s3/src/s3-client/signer.ts @@ -0,0 +1,149 @@ +/** + * AWS Signature Version 4 Pre-signed URL Generator for S3-compatible services. + * Generates pre-signed URLs with signature in the query string. + * @see https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html + */ + +import * as C from './consts.js' +import type { PresignableRequest, presignedResponse } from './types.js' +import * as U from './utils.js' + +export interface SignerConfig { + accessKeyId: string + secretAccessKey: string + sessionToken?: string + region: string + endpoint: string + service?: string +} + +// Default expiry: 1 hour. This is how long the URL is valid before the request starts. +const DEFAULT_EXPIRES_IN = 3600 + +/** + * Creates a SigV4 pre-signed URL generator for S3 requests. + * Returns a function that generates pre-signed URLs. + */ +export function createSigV4Presigner(config: SignerConfig) { + const { + accessKeyId, + secretAccessKey, + sessionToken, + region, + endpoint, + service = C.S3_SERVICE, + } = config + + let signingKeyDate: string | null = null + let signingKey: ArrayBuffer | null = null + + async function getSignatureKey(dateStamp: string): Promise { + const kDate = await U.hmac(`AWS4${secretAccessKey}`, dateStamp) + const kRegion = await U.hmac(kDate, region) + const kService = await U.hmac(kRegion, service) + return U.hmac(kService, C.AWS_REQUEST_TYPE) + } + + return async function presign( + request: PresignableRequest, + ): Promise { + const { method, key, expiresIn = DEFAULT_EXPIRES_IN } = request + + // Build the URL - need to track encoded path separately because URL object decodes it + const url = new URL(endpoint) + + // Normalize key: strip leading slashes to prevent double slashes when building path + // e.g., endpoint "/" + key "/file.txt" should become "/file.txt", not "//file.txt" + const normalizedKey = key ? key.replace(/^\/+/, '') : '' + const encodedKey = normalizedKey ? U.uriResourceEscape(normalizedKey) : '' + + // Build the canonical path (must be encoded for signing) + let canonicalPath = url.pathname + if (encodedKey) { + canonicalPath = canonicalPath.endsWith('/') + ? `${canonicalPath}${encodedKey}` + : `${canonicalPath}/${encodedKey}` + } + + const now = new Date() + const shortDate = now.toISOString().slice(0, 10).replace(/-/g, '') + const fullDatetime = `${shortDate}T${now.toISOString().slice(11, 19).replace(/:/g, '')}Z` + const credential = `${accessKeyId}/${shortDate}/${region}/${service}/${C.AWS_REQUEST_TYPE}` + + // Build query parameters for pre-signed URL + url.searchParams.set('X-Amz-Algorithm', C.AWS_ALGORITHM) + // UNSIGNED_PAYLOAD tells AWS not to verify the body content. + // Required for pre-signed URLs since the body doesn't exist at signing time. + url.searchParams.set('X-Amz-Content-Sha256', C.UNSIGNED_PAYLOAD) + url.searchParams.set('X-Amz-Credential', credential) + url.searchParams.set('X-Amz-Date', fullDatetime) + url.searchParams.set('X-Amz-Expires', String(expiresIn)) + url.searchParams.set('X-Amz-SignedHeaders', 'host') + + // Add session token if present + if (sessionToken) { + url.searchParams.set('X-Amz-Security-Token', sessionToken) + } + + // Add multipart-specific params + if ('uploadId' in request) { + url.searchParams.set('uploadId', request.uploadId) + } + if ('partNumber' in request) { + url.searchParams.set('partNumber', String(request.partNumber)) + } + + // For CreateMultipartUpload, add uploads param + if (method === 'POST' && !('uploadId' in request)) { + url.searchParams.set('uploads', '') + } + + // Sort query params (AWS SigV4 requires ASCII byte ordering) + url.searchParams.sort() + + // Build canonical query string (replace + with %20 as required for AWS) + const sortedParams = url.searchParams.toString().replace(/\+/g, '%20') + + // Build canonical request + const canonicalHeaders = `host:${url.host}` + const signedHeaderNames = 'host' + + const canonicalRequest = [ + method, + canonicalPath, // Use the encoded path, not url.pathname which gets decoded + sortedParams, + canonicalHeaders, + '', + signedHeaderNames, + C.UNSIGNED_PAYLOAD, + ].join('\n') + + // Build string to sign + const scope = `${shortDate}/${region}/${service}/${C.AWS_REQUEST_TYPE}` + const stringToSign = [ + C.AWS_ALGORITHM, + fullDatetime, + scope, + U.hexFromBuffer(await U.sha256(canonicalRequest)), + ].join('\n') + + // Get/cache signing key + if (shortDate !== signingKeyDate || !signingKey) { + signingKeyDate = shortDate + signingKey = await getSignatureKey(shortDate) + } + + // Generate signature + const signature = U.hexFromBuffer(await U.hmac(signingKey, stringToSign)) + + // Build final URL with signature (use canonicalPath to preserve encoding) + const presignedUrl = `${url.origin}${canonicalPath}?${sortedParams}&X-Amz-Signature=${signature}` + + return { + url: presignedUrl, + } + } +} + +// Keep the old name as an alias for backward compatibility during migration +export const createSigV4Signer = createSigV4Presigner diff --git a/packages/@uppy/aws-s3/src/s3-client/types.ts b/packages/@uppy/aws-s3/src/s3-client/types.ts new file mode 100644 index 0000000000..889dc465a0 --- /dev/null +++ b/packages/@uppy/aws-s3/src/s3-client/types.ts @@ -0,0 +1,133 @@ +export interface PresignableRequestBase { + key: string + expiresIn?: number +} + +export interface PutObjectRequest extends PresignableRequestBase { + method: 'PUT' +} +export interface DeleteObjectRequest extends PresignableRequestBase { + method: 'DELETE' +} +export interface CreateMultipartUploadRequest extends PresignableRequestBase { + method: 'POST' +} +export interface CompleteMultipartUploadRequest extends PresignableRequestBase { + method: 'POST' + uploadId: string +} +export interface DeleteMultipartUploadRequest extends PresignableRequestBase { + method: 'DELETE' + uploadId: string +} +export interface ListPartsRequest extends PresignableRequestBase { + method: 'GET' + uploadId: string +} +export interface UploadPartRequest extends PresignableRequestBase { + method: 'PUT' + uploadId: string + partNumber: number +} + +/** Request data to be pre-signed */ +export type PresignableRequest = + | PutObjectRequest + | DeleteObjectRequest + | CreateMultipartUploadRequest + | CompleteMultipartUploadRequest + | DeleteMultipartUploadRequest + | ListPartsRequest + | UploadPartRequest + +/** Response with the pre-signed URL */ +export type presignedResponse = { + url: string +} + +/** Function that generates a pre-signed URL for a request */ +export type SignRequestFn = ( + request: PresignableRequest, +) => Promise + +/** + * Temporary security credentials from STS or similar service. + * These are used with getCredentials callback for client-side signing. + */ +export interface TemporaryCredentials { + accessKeyId: string + secretAccessKey: string + sessionToken: string + /** ISO 8601 date string when credentials expire */ + expiration?: string +} + +/** + * Response from getCredentials callback. + * Includes temporary credentials plus region info. + */ +export interface CredentialsResponse { + credentials: TemporaryCredentials + region: string +} + +/** Function that retrieves temporary credentials */ +export type GetCredentialsFn = (options?: { + signal?: AbortSignal +}) => CredentialsResponse | Promise + +/** Base configuration shared by both signing approaches */ +type S3ConfigBase = { + /** AWS region. Defaults to 'auto'. */ + region?: string | undefined + /** Request size in bytes for multipart uploads. Defaults to 8MB. */ + requestSizeInBytes?: number | undefined + /** Timeout in ms after which a request should be aborted. */ + requestAbortTimeout?: number | undefined +} + +/** Config when using signRequest callback (region optional) */ +type S3ConfigWithSignRequest = S3ConfigBase & { + /** Function to sign requests. Called for each S3 API request. */ + signRequest: SignRequestFn +} + +/** Config when using getCredentials callback (region required for signing) */ +type S3ConfigWithGetCredentials = Omit & { + /** Function to retrieve temporary credentials for client-side signing. */ + getCredentials: GetCredentialsFn + /** AWS region. Required for signing with getCredentials. */ + region?: string + /** Endpoint URL of the S3-compatible service (e.g., 'https://s3.amazonaws.com/bucket-name') */ + endpoint: string +} + +/** Configuration options for S3mini client */ +export type S3Config = S3ConfigWithSignRequest | S3ConfigWithGetCredentials + +export interface UploadPart { + partNumber: number + etag: string +} + +export interface ErrorWithCode { + code?: string + cause?: { code?: string } +} + +export type HttpMethod = 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE' + +export type XmlValue = string | XmlMap | boolean | number | null +export interface XmlMap { + [key: string]: XmlValue | XmlValue[] // one or many children + [key: number]: XmlValue | XmlValue[] // allow numeric keys +} + +/** + * Binary data types supported in browser environments. + * Use ArrayBuffer, Uint8Array, or Blob - Buffer is not available in browsers. + */ +export type BinaryData = ArrayBuffer | Uint8Array | Blob + +/** Progress callback for upload operations */ +export type OnProgressFn = (bytesUploaded: number, bytesTotal: number) => void diff --git a/packages/@uppy/aws-s3/src/s3-client/utils.ts b/packages/@uppy/aws-s3/src/s3-client/utils.ts new file mode 100644 index 0000000000..26beff568d --- /dev/null +++ b/packages/@uppy/aws-s3/src/s3-client/utils.ts @@ -0,0 +1,175 @@ +import type { XmlMap, XmlValue } from './types.js' + +export const sanitizeXmlETag = (etag: string): string => + etag.replace(/^("|")+|("|")+$/g, '') + +export const sanitizeETag = (etag: string | null): string | undefined => + etag?.replace(/^"+|"+$/g, '') + +/** Strips query string and hash from a URL to derive the object location. */ +export function removeQueryString(urlString: string): string { + const urlObject = new URL(urlString) + urlObject.search = '' + urlObject.hash = '' + return urlObject.href +} + +const textEncoder = new TextEncoder() +const HEXS = '0123456789abcdef' + +/** + * Turn a raw ArrayBuffer into its hexadecimal representation. + * @param {ArrayBuffer} buffer The raw bytes. + * @returns {string} Hexadecimal string + */ +export const hexFromBuffer = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer) + let hex = '' + for (const byte of bytes) { + hex += HEXS[byte >> 4]! + HEXS[byte & 0x0f]! + } + return hex +} + +/** + * Compute SHA-256 hash of arbitrary string data. + * @param {string} content The content to be hashed. + * @returns {ArrayBuffer} The raw hash + */ +export const sha256 = async (content: string): Promise => { + const data = textEncoder.encode(content) + + return await globalThis.crypto.subtle.digest('SHA-256', data) +} + +/** + * Compute HMAC-SHA-256 of arbitrary data. + * @param {string|ArrayBuffer} key The key used to sign the content. + * @param {string} content The content to be signed. + * @returns {ArrayBuffer} The raw signature + */ +export const hmac = async ( + key: string | ArrayBuffer, + content: string, +): Promise => { + const secret = await globalThis.crypto.subtle.importKey( + 'raw', + typeof key === 'string' ? textEncoder.encode(key) : key, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + const data = textEncoder.encode(content) + + return await globalThis.crypto.subtle.sign('HMAC', secret, data) +} + +const entityMap = { + '"': '"', + ''': "'", + '<': '<', + '>': '>', + '&': '&', +} as const + +const unescapeXml = (value: string): string => + value.replaceAll( + /&(quot|apos|lt|gt|amp);/g, + (m) => entityMap[m as keyof typeof entityMap] ?? m, + ) + +/** + * Parse a very small subset of XML into a JS structure. + * + * @param input raw XML string + * @returns string for leaf nodes, otherwise a map of children + */ + +export const parseXml = (input: string): XmlValue => { + const xmlContent = input.replace(/<\?xml[^?]*\?>\s*/, '') + const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*>([\s\S]*?)<\/\1>/gm + const result: XmlMap = {} // strong type, no `any` + let match: RegExpExecArray | null + + // biome-ignore lint/suspicious/noAssignInExpressions: suppress + while ((match = RE_TAG.exec(xmlContent)) !== null) { + const tagName = match[1] + const innerContent = match[2] + const node: XmlValue = innerContent + ? parseXml(innerContent) + : unescapeXml(innerContent?.trim() || '') + if (!tagName) { + continue + } + const current = result[tagName] + if (current === undefined) { + // First occurrence + result[tagName] = node + } else if (Array.isArray(current)) { + // Already an array + current.push(node) + } else { + // Promote to array on the second occurrence + result[tagName] = [current, node] + } + } + + // No child tags? — return the text, after entity decode + return Object.keys(result).length > 0 + ? result + : unescapeXml(xmlContent.trim()) +} + +/** + * Encode a character as a URI percent-encoded hex value + * @param c Character to encode + * @returns Percent-encoded character + */ +const encodeAsHex = (c: string): string => + `%${c.charCodeAt(0).toString(16).toUpperCase()}` + +/** + * Escape a URI string using percent encoding + * @param uriStr URI string to escape + * @returns Escaped URI string + */ +const uriEscape = (uriStr: string): string => { + return encodeURIComponent(uriStr).replace(/[!'()*]/g, encodeAsHex) +} + +/** + * Escape a URI resource path while preserving forward slashes + * @param string URI path to escape + * @returns Escaped URI path + */ +export const uriResourceEscape = (string: string): string => { + return uriEscape(string).replaceAll('%2F', '/') +} + +export class S3Error extends Error { + readonly code?: string + constructor(msg: string, code?: string, cause?: unknown) { + super(msg) + this.name = new.target.name // keeps instanceof usable + this.code = code + this.cause = cause + } +} + +export class S3NetworkError extends S3Error {} +export class S3ServiceError extends S3Error { + readonly status: number + readonly serviceCode?: string + body: string | undefined + constructor( + msg: string, + status: number, + serviceCode?: string, + body?: string, + ) { + super(msg, serviceCode) + this.status = status + this.serviceCode = serviceCode + this.body = body + } +} diff --git a/packages/@uppy/aws-s3/src/utils.ts b/packages/@uppy/aws-s3/src/utils.ts index d5a2700b03..27804f894f 100644 --- a/packages/@uppy/aws-s3/src/utils.ts +++ b/packages/@uppy/aws-s3/src/utils.ts @@ -1,4 +1,3 @@ -import type { Body } from '@uppy/utils' import { createAbortError } from '@uppy/utils' import type { AwsS3Part } from './index.js' @@ -22,9 +21,3 @@ export type UploadPartBytesResult = { ETag: string location?: string } - -export interface AwsBody extends Body { - location: string - key: string - bucket: string -} diff --git a/packages/@uppy/aws-s3/tests/index.test.ts b/packages/@uppy/aws-s3/tests/index.test.ts new file mode 100644 index 0000000000..c52ebf176a --- /dev/null +++ b/packages/@uppy/aws-s3/tests/index.test.ts @@ -0,0 +1,494 @@ +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest' + +import 'whatwg-fetch' +import Core, { type Meta, type UppyFile } from '@uppy/core' +import AwsS3, { type AwsBody, type AwsS3Options } from '../src/index.js' + +const KB = 1024 +const MB = KB * KB + +// --------------------------------------------------------------------------- +// Helpers for multipart upload tests +// --------------------------------------------------------------------------- + +/** Minimal XML responses that S3 returns for multipart operations */ +const s3Responses = { + createMultipart: (uploadId: string, key: string) => + ` + + ${uploadId} + ${key} + `, + + uploadPart: (etag: string) => '', + + listParts: (parts: { partNumber: number; etag: string }[]) => + ` + + ${parts.map((p) => `${p.partNumber}${p.etag}`).join('')} + `, + + completeMultipart: (location: string, key: string) => + ` + + ${location} + ${key} + test-bucket + `, + + abortMultipart: () => '', +} + +const server = setupServer() +const s3Url = 'https://test-bucket.s3.us-east-1.amazonaws.com/:key' + +/** + * Creates signRequest + MSW handler state for multipart upload tests. + */ +function createMultipartMocks(opts: { uploadId?: string; key?: string } = {}) { + const uploadId = opts.uploadId ?? 'test-upload-id' + const key = opts.key ?? 'test-key' + + // signRequest encodes operation details in the URL for MSW routing + const signRequest = vi.fn().mockImplementation(async (req: any) => { + const params = new URLSearchParams() + if (req.uploadId) params.set('uploadId', req.uploadId) + if (req.partNumber) params.set('partNumber', String(req.partNumber)) + params.set('method', req.method) + return { + url: `https://test-bucket.s3.us-east-1.amazonaws.com/${req.key || key}?${params}`, + } + }) + + const operations: string[] = [] + + const registerHandlers = ({ + hangNonCreate = false, + listParts = [] as { partNumber: number; etag: string }[], + } = {}) => { + const maybeHang = () => + hangNonCreate ? (new Promise(() => {}) as Promise) : null + + server.use( + http.post(s3Url, ({ request }) => { + const hasUploadId = new URL(request.url).searchParams.has('uploadId') + if (!hasUploadId) { + operations.push('createMultipart') + return new HttpResponse(s3Responses.createMultipart(uploadId, key), { + status: 200, + headers: { 'Content-Type': 'application/xml' }, + }) + } + + operations.push('completeMultipart') + const hung = maybeHang() + if (hung) return hung + + return new HttpResponse( + s3Responses.completeMultipart( + `https://test-bucket.s3.amazonaws.com/${key}`, + key, + ), + { status: 200, headers: { 'Content-Type': 'application/xml' } }, + ) + }), + http.put(s3Url, () => { + operations.push('uploadPart') + const hung = maybeHang() + if (hung) return hung + return new HttpResponse('', { + status: 200, + headers: { ETag: '"etag-1"' }, + }) + }), + http.get(s3Url, ({ request }) => { + const hasUploadId = new URL(request.url).searchParams.has('uploadId') + if (!hasUploadId) { + return new HttpResponse('Not Found', { status: 404 }) + } + + operations.push('listParts') + const hung = maybeHang() + if (hung) return hung + + return new HttpResponse(s3Responses.listParts(listParts), { + status: 200, + headers: { 'Content-Type': 'application/xml' }, + }) + }), + http.delete(s3Url, () => { + operations.push('abortMultipart') + return new HttpResponse('', { status: 204 }) + }), + ) + } + + return { signRequest, operations, uploadId, key, registerHandlers } +} + +describe('AwsS3', () => { + beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }) + }) + + afterEach(() => { + server.resetHandlers() + }) + + afterAll(() => { + server.close() + }) + + it('Registers AwsS3 upload plugin', () => { + const core = new Core().use(AwsS3, { + region: 'us-east-1', + s3Endpoint: 'https://companion.example.com', + companionEndpoint: 'https://companion.example.com', + }) + + const pluginNames = core[Symbol.for('uppy test: getPlugins')]( + 'uploader', + ).map((plugin: AwsS3) => plugin.constructor.name) + expect(pluginNames).toContain('AwsS3') + }) + + describe('configuration validation', () => { + it('throws if no signing method is provided', () => { + expect(() => { + const core = new Core() + // @ts-expect-error - testing runtime validation, so omit required options + core.use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + }) + }).toThrow( + 'One of options `companionEndpoint`, `signRequest`, or `getCredentials` is required', + ) + }) + + it('accepts endpoint option', () => { + const core = new Core() + core.use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + companionEndpoint: 'https://companion.example.com', + }) + expect(core.getPlugin('AwsS3')).toBeDefined() + }) + + it('accepts signRequest option', () => { + const core = new Core() + core.use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + signRequest: vi.fn(), + }) + expect(core.getPlugin('AwsS3')).toBeDefined() + }) + + it('accepts getCredentials option', () => { + const core = new Core() + core.use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + getCredentials: vi.fn(), + }) + expect(core.getPlugin('AwsS3')).toBeDefined() + }) + }) + + describe('shouldUseMultipart', () => { + const MULTIPART_THRESHOLD = 100 * MB + + // Helper that creates a mock file without allocating memory + const createFile = (size: number): UppyFile => + ({ + name: 'test.dat', + size, + data: { size } as Blob, + }) as unknown as UppyFile + + it('defaults to multipart for files > 100MB', () => { + const core = new Core().use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + companionEndpoint: 'https://companion.example.com', + }) + const opts = core.getPlugin('AwsS3')!.opts as AwsS3Options + const shouldUseMultipart = opts.shouldUseMultipart as ( + file: UppyFile, + ) => boolean + + expect(shouldUseMultipart(createFile(MULTIPART_THRESHOLD + 1))).toBe(true) + expect(shouldUseMultipart(createFile(MULTIPART_THRESHOLD))).toBe(false) + expect(shouldUseMultipart(createFile(MULTIPART_THRESHOLD - 1))).toBe( + false, + ) + expect(shouldUseMultipart(createFile(0))).toBe(false) + }) + + it('handles very large files', () => { + const core = new Core().use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + companionEndpoint: 'https://companion.example.com', + }) + const opts = core.getPlugin('AwsS3')!.opts as AwsS3Options + const shouldUseMultipart = opts.shouldUseMultipart as ( + file: UppyFile, + ) => boolean + + expect(shouldUseMultipart(createFile(70 * 1024 * MB))).toBe(true) // 70GB + expect(shouldUseMultipart(createFile(400 * 1024 * MB))).toBe(true) // 400GB + }) + }) + + describe('upload events', () => { + it('emits upload-start when upload begins', async () => { + const signRequest = vi.fn().mockRejectedValue(new Error('Test stop')) + + const core = new Core().use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + signRequest, + shouldUseMultipart: false, + }) + + core.addFile({ + source: 'test', + name: 'test.txt', + type: 'text/plain', + data: new File([new Uint8Array(1024)], 'test.txt'), + }) + + const uploadStartHandler = vi.fn() + core.on('upload-start', uploadStartHandler) + + try { + await core.upload() + } catch { + // Expected + } + + expect(uploadStartHandler).toHaveBeenCalledTimes(1) + }) + + it('emits upload-error on failure', async () => { + const signRequest = vi.fn().mockRejectedValue(new Error('Sign failed')) + + const core = new Core().use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + signRequest, + shouldUseMultipart: false, + }) + + core.addFile({ + source: 'test', + name: 'test.txt', + type: 'text/plain', + data: new File([new Uint8Array(1024)], 'test.txt'), + }) + + const uploadErrorHandler = vi.fn() + core.on('upload-error', uploadErrorHandler) + + try { + await core.upload() + } catch { + // Expected + } + + expect(uploadErrorHandler).toHaveBeenCalledTimes(1) + }) + }) + + describe('abort', () => { + it('aborts when file is removed', async () => { + const signRequest = vi + .fn() + .mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ) + + const core = new Core().use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + signRequest, + shouldUseMultipart: false, + }) + + core.addFile({ + source: 'test', + name: 'test.txt', + type: 'text/plain', + data: new File([new Uint8Array(1024)], 'test.txt'), + }) + + const fileId = Object.keys(core.getState().files)[0] + const uploadPromise = core.upload() + setTimeout(() => core.removeFile(fileId), 10) + + const result = await uploadPromise + // When a file is removed mid-upload, it should not appear in successful uploads + expect(result).toBeDefined() + expect(result?.successful).toHaveLength(0) + }) + + it('aborts when cancelAll is called', async () => { + const signRequest = vi + .fn() + .mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ) + + const core = new Core().use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + signRequest, + shouldUseMultipart: false, + }) + + core.addFile({ + source: 'test', + name: 'test.txt', + type: 'text/plain', + data: new File([new Uint8Array(1024)], 'test.txt'), + }) + + const uploadPromise = core.upload() + setTimeout(() => core.cancelAll(), 10) + + const result = await uploadPromise + // When cancelAll is called, no files should complete successfully + expect(result).toBeDefined() + expect(result?.successful).toHaveLength(0) + }) + }) + + describe('Golden Retriever resume state (s3Multipart)', () => { + it('persists s3Multipart on file state after creating multipart upload', async () => { + const { signRequest, uploadId, registerHandlers } = createMultipartMocks() + // After createMultipart succeeds, hang on subsequent requests so we can inspect state + registerHandlers({ hangNonCreate: true }) + + const core = new Core().use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + signRequest, + shouldUseMultipart: true, + }) + + const fileId = core.addFile({ + source: 'test', + name: 'big.dat', + type: 'application/octet-stream', + data: new File([new Uint8Array(6 * MB)], 'big.dat'), + }) + + const uploadPromise = core.upload() + + // Wait for createMultipart to complete and state to be persisted + await new Promise((resolve) => setTimeout(resolve, 100)) + + const file = core.getFile(fileId) + expect(file.s3Multipart).toBeDefined() + expect(file.s3Multipart?.uploadId).toBe(uploadId) + + // Clean up + core.cancelAll() + await uploadPromise + }) + + it('clears s3Multipart when upload is aborted via cancelAll', async () => { + const { signRequest, registerHandlers } = createMultipartMocks({ + uploadId: 'cancel-test-id', + key: 'cancel-key', + }) + registerHandlers({ hangNonCreate: true }) + + const core = new Core().use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + signRequest, + shouldUseMultipart: true, + }) + + const fileId = core.addFile({ + source: 'test', + name: 'big.dat', + type: 'application/octet-stream', + data: new File([new Uint8Array(6 * MB)], 'big.dat'), + }) + + const uploadPromise = core.upload() + + // Wait for createMultipart, then cancel + await new Promise((resolve) => setTimeout(resolve, 50)) + core.cancelAll() + + await uploadPromise + + const file = core.getFile(fileId) + // s3Multipart should be cleared so retries don't use a dead uploadId + expect(file?.s3Multipart).toBeUndefined() + }) + + it('uses persisted s3Multipart key for resume (listParts, not createMultipart)', async () => { + const persistedKey = 'persisted-object-key' + const persistedUploadId = 'persisted-upload-id' + const { signRequest, operations, registerHandlers } = + createMultipartMocks({ + uploadId: persistedUploadId, + key: persistedKey, + }) + registerHandlers() + + const core = new Core().use(AwsS3, { + s3Endpoint: 'https://companion.example.com', + region: 'us-east-1', + signRequest, + shouldUseMultipart: false, // Would normally be simple upload + }) + + const fileId = core.addFile({ + source: 'test', + name: 'big.dat', + type: 'application/octet-stream', + data: new File([new Uint8Array(6 * MB)], 'big.dat'), + }) + + // Simulate Golden Retriever restoring s3Multipart state + core.setFileState(fileId, { + s3Multipart: { uploadId: persistedUploadId, key: persistedKey }, + }) + + const uploadPromise = core.upload() + + // Wait for the resume flow to call listParts (via fetch), then cancel + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should have resumed (listParts) instead of creating a new multipart upload + expect(operations).toContain('listParts') + expect(operations).not.toContain('createMultipart') + + // The signRequest calls should use the persisted key, not a generated one + const signedKeys = signRequest.mock.calls.map((call: any) => call[0].key) + expect(signedKeys.every((k: string) => k === persistedKey)).toBe(true) + + // Clean up + core.cancelAll() + await uploadPromise + }) + }) +}) diff --git a/packages/@uppy/aws-s3/tests/s3-client/compose.minio.yaml b/packages/@uppy/aws-s3/tests/s3-client/compose.minio.yaml new file mode 100644 index 0000000000..035e83df93 --- /dev/null +++ b/packages/@uppy/aws-s3/tests/s3-client/compose.minio.yaml @@ -0,0 +1,20 @@ +services: + minio: + image: quay.io/minio/minio:RELEASE.2025-07-23T15-54-02Z-cpuv1 + ports: + - 9002:9002 + - 9003:9003 + restart: always + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_ADDRESS: ':9002' + MINIO_CONSOLE_ADDRESS: ':9003' + MC_HOST_local: 'http://${MINIO_ROOT_USER}:${MINIO_ROOT_PASSWORD}@localhost:9002' + command: minio server /data + healthcheck: + test: mc ready local + start_period: 5s + interval: 30s + timeout: 3s + retries: 3 diff --git a/packages/@uppy/aws-s3/tests/s3-client/config.ts b/packages/@uppy/aws-s3/tests/s3-client/config.ts new file mode 100644 index 0000000000..4f1a937539 --- /dev/null +++ b/packages/@uppy/aws-s3/tests/s3-client/config.ts @@ -0,0 +1,14 @@ +export const accessKeyId = 'stsuser' +export const secretAccessKey = 'stspassword123' + +export function getConfig(env: Record) { + const config = env['VITE_MINIO_CONFIG'] + if (!config) { + return undefined + } + const [rootAccessKeyId, rootSecretAccessKey, endpoint, region] = + config.split(',') + // Use stsuser credentials for all tests (readwrite policy is sufficient) + // Root credentials are kept for Docker container startup + return { endpoint, region, rootAccessKeyId, rootSecretAccessKey } +} diff --git a/packages/@uppy/aws-s3/tests/s3-client/docker.ts b/packages/@uppy/aws-s3/tests/s3-client/docker.ts new file mode 100644 index 0000000000..93ddaf2763 --- /dev/null +++ b/packages/@uppy/aws-s3/tests/s3-client/docker.ts @@ -0,0 +1,89 @@ +import { exec, spawn } from 'node:child_process' +import { promisify } from 'node:util' + +const execAsync = promisify(exec) + +export async function getContainerName(serviceName: string) { + // Find container by service name using docker ps + const { stdout } = await execAsync( + `docker ps --filter "label=com.docker.compose.service=${serviceName}" --format "{{.Names}}"`, + ) + const containerName = stdout.trim().split('\n')[0] // Get first matching container + if (!containerName) { + throw new Error(`No running container found for service: ${serviceName}`) + } + return containerName +} + +export async function execDockerCommand( + containerName: string, + command: string, + timeoutMs = 10000, +) { + // If it's a service name (like 'garage'), find the actual container name + let actualContainerName = containerName + if (['garage', 'minio', 'ceph'].includes(containerName)) { + try { + actualContainerName = await getContainerName(containerName) + // console.log(`Found container: ${actualContainerName} for service: ${containerName}`,) + } catch (_error) { + console.log(`Using container name as-is: ${containerName}`) + } + } + + const dockerCommand = `docker exec ${actualContainerName} ${command}` + + try { + // Add timeout to prevent hanging + const execPromise = execAsync(dockerCommand, { timeout: timeoutMs }) + const { stdout, stderr } = await execPromise + + if (stderr && !stderr.includes('WARNING')) { + console.warn(`Warning from docker exec: ${stderr}`) + } + return stdout.trim() + } catch (error) { + // Handle timeout specifically + if (error.killed && error.signal === 'SIGTERM') { + throw new Error( + `Command timed out after ${timeoutMs}ms: ${dockerCommand}`, + ) + } + + console.error(`Docker exec error details:`, { + command: dockerCommand, + message: error.message, + stderr: error.stderr, + stdout: error.stdout, + code: error.code, + killed: error.killed, + signal: error.signal, + }) + throw error + } +} + +async function run(cmd: string, args: string[], env?: Record) { + // console.log('running command:', cmd, args.join(' ')) + return new Promise((res, rej) => { + const p = spawn(cmd, args, { + stdio: 'inherit', + env: { ...process.env, ...env }, + }) + p.on('close', (code) => + code === 0 + ? res() + : rej(new Error(`${cmd} ${args.join(' ')} exited ${code}`)), + ) + p.on('error', (err) => rej(err)) + }) +} + +export const composeUpWait = (file: string, env?: Record) => + run( + 'docker', + ['compose', '-f', file, 'up', '-d', '--force-recreate', '--wait'], + env, + ) +export const composeDown = (file: string) => + run('docker', ['compose', '-f', file, 'down', '--remove-orphans', '-v']) diff --git a/packages/@uppy/aws-s3/tests/s3-client/minio.test.ts b/packages/@uppy/aws-s3/tests/s3-client/minio.test.ts new file mode 100644 index 0000000000..190c13c15c --- /dev/null +++ b/packages/@uppy/aws-s3/tests/s3-client/minio.test.ts @@ -0,0 +1,456 @@ +import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts' +import { beforeAll, describe, expect, it } from 'vitest' +import { S3mini } from '../../src/s3-client/S3mini.js' +import { createSigV4Signer } from '../../src/s3-client/signer.js' +import { randomBytes } from '../test-utils/browser-crypto.js' +import { accessKeyId, getConfig, secretAccessKey } from './config.js' + +// @ts-expect-error todo +const config = getConfig(import.meta.env) + +const suiteName = 'MinIO S3 client tests' + +if (config) { + const { endpoint, region } = config + const presigner = createSigV4Signer({ + accessKeyId, + secretAccessKey, + region, + endpoint, + }) + + const s3client = new S3mini({ + endpoint, + region, + signRequest: presigner, + }) + + const EIGHT_MB = 8 * 1024 * 1024 + const key_bin = 'test-multipart.bin' + + const large_buffer = randomBytes(EIGHT_MB * 3.2) + const content = 'some content' + const key = 'first-test-object.txt' + const key_list_parts = 'test-list-parts.bin' + const key_abort_multipart = 'test-abort-multipart.bin' + + const FILE_KEYS = [key, key_bin, key_list_parts, key_abort_multipart] + + beforeAll(async () => { + for (const key of FILE_KEYS) { + try { + await s3client.deleteObject(key) + } catch { + // intentionally ignore the errors as the objects don't exists. still feels hacky tbh + } + } + }) + + /** Create STS client using @aws-sdk/client-sts */ + function createSTSClient({ endpoint, accessKeyId, secretAccessKey, region }) { + return new STSClient({ + region, + endpoint, + credentials: { accessKeyId, secretAccessKey }, + }) + } + + /** Get temporary credentials via AssumeRole */ + async function assumeRole( + stsClient: STSClient, + { durationSeconds = 900 } = {}, + ) { + const response = await stsClient.send( + new AssumeRoleCommand({ + RoleArn: 'aws:iam::000000000000:role/test-role', // MinIO doesn't validate ARN + RoleSessionName: 'uppy-test', + DurationSeconds: durationSeconds, + }), + ) + const creds = response.Credentials! + return { + AccessKeyId: creds.AccessKeyId, + SecretAccessKey: creds.SecretAccessKey, + SessionToken: creds.SessionToken, + Expiration: creds.Expiration?.toISOString() || creds.Expiration, + } + } + + // ===== signRequest tests ===== + + describe(suiteName, () => { + it('simple putObject upload', async () => { + const key = 'presigned-test-file.txt' + const fileContents = new TextEncoder().encode( + 'Hello from pre-signed URL test.', + ) + + const result = await s3client.putObject(key, fileContents, 'text/plain') + + expect(result.location).toBeDefined() + expect(result.location).toContain(key) + // location should be a clean URL without query string (no signing params) + expect(result.location).not.toContain('X-Amz-Signature') + expect(result.location).not.toContain('?') + }) + + it('multipart upload with signRequest', async () => { + const key = `presigned-multipart-${Date.now()}.bin` + const partSize = 5 * 1024 * 1024 // 5MB + const part = randomBytes(partSize) + + const { uploadId } = await s3client.createMultipartUpload( + key, + 'application/octet-stream', + ) + expect(uploadId).toBeDefined() + + const partNumber = 1 + const { etag } = await s3client.uploadPart( + key, + uploadId, + part, + partNumber, + ) + expect(etag).toBeDefined() + + const result = await s3client.completeMultipartUpload(key, uploadId, [ + { etag, partNumber }, + ]) + expect(result.etag).toBeDefined() + expect(result.location).toBeDefined() + expect(result.location).toContain(key) + expect(result.key).toBe(key) + + await s3client.deleteObject(key) + }) + + // ===== getCredentials tests (STS) ===== + + describe('getCredentials (STS)', () => { + const stsEndpoint = new URL(endpoint).origin + + it('should upload using getCredentials callback', async () => { + const testKey = `sts-getcreds-${Date.now()}.txt` + + const s3 = new S3mini({ + endpoint, + region, + getCredentials: async () => { + const stsClient = createSTSClient({ + endpoint: stsEndpoint, + accessKeyId, + secretAccessKey, + region, + }) + const creds = await assumeRole(stsClient) + return { + credentials: { + accessKeyId: creds.AccessKeyId!, + secretAccessKey: creds.SecretAccessKey!, + sessionToken: creds.SessionToken!, + expiration: creds.Expiration as string, + }, + bucket: new URL(endpoint).pathname.slice(1), + region, + } + }, + }) + + try { + const result = await s3.putObject(testKey, 'Hello STS!', 'text/plain') + expect(result.location).toBeDefined() + expect(result.location).toContain(testKey) + expect(result.location).not.toContain('X-Amz-Signature') + expect(result.location).not.toContain('?') + } finally { + await s3.deleteObject(testKey) + } + }) + + it('should cache credentials across multiple requests', async () => { + let fetchCount = 0 + + const s3 = new S3mini({ + endpoint, + region, + getCredentials: async () => { + fetchCount++ + const stsClient = createSTSClient({ + endpoint: stsEndpoint, + accessKeyId, + secretAccessKey, + region, + }) + const creds = await assumeRole(stsClient) + return { + credentials: { + accessKeyId: creds.AccessKeyId!, + secretAccessKey: creds.SecretAccessKey!, + sessionToken: creds.SessionToken!, + expiration: creds.Expiration as string, + }, + bucket: new URL(endpoint).pathname.slice(1), + region, + } + }, + }) + + await s3.putObject( + `sts-cache-1-${Date.now()}.txt`, + 'File 1', + 'text/plain', + ) + await s3.putObject( + `sts-cache-2-${Date.now()}.txt`, + 'File 2', + 'text/plain', + ) + expect(fetchCount).toBe(1) + }) + + it('should perform multipart upload with getCredentials', async () => { + const s3 = new S3mini({ + endpoint, + region, + getCredentials: async () => { + const stsClient = createSTSClient({ + endpoint: stsEndpoint, + accessKeyId, + secretAccessKey, + region, + }) + const creds = await assumeRole(stsClient) + return { + credentials: { + accessKeyId: creds.AccessKeyId!, + secretAccessKey: creds.SecretAccessKey!, + sessionToken: creds.SessionToken!, + expiration: creds.Expiration as string, + }, + bucket: new URL(endpoint).pathname.slice(1), + region, + } + }, + }) + + const key = `sts-multipart-${Date.now()}.bin` + const partSize = 5 * 1024 * 1024 // 5MB + const part = randomBytes(partSize) + + const { uploadId } = await s3.createMultipartUpload( + key, + 'application/octet-stream', + ) + expect(uploadId).toBeDefined() + + const partNumber = 1 + const { etag } = await s3.uploadPart(key, uploadId, part, partNumber) + expect(etag).toBeDefined() + + const result = await s3.completeMultipartUpload(key, uploadId, [ + { etag, partNumber }, + ]) + expect(result.etag).toBeDefined() + expect(result.location).toBeDefined() + expect(result.location).toContain(key) + expect(result.key).toBe(key) + + await s3.deleteObject(key) + }) + }) + + it('instantiates s3client', () => { + expect(s3client).toBeInstanceOf(S3mini) // ← updated expectation + }) + + // we don't need an explicit eTag method as we already get eTag in the putOject response + it('putObject uploads successfully and returns ETag', async () => { + const response = await s3client.putObject(key, content, 'text/plain') + expect(response.etag).toBeDefined() + await s3client.deleteObject(key) + }) + + it('putObject handles binary data', async () => { + const binaryData = new Uint8Array(6).fill(0xff) + + const response = await s3client.putObject( + key, + binaryData, + 'application/octet-stream', + ) + + expect(response).toBeDefined() + expect(response.etag).toBeDefined() + + // cleanup + + await s3client.deleteObject(key) + }) + + // test createMultipartUpload + + it('createMultipartUpload returns a valid uploadId', async () => { + const { uploadId } = await s3client.createMultipartUpload( + key_bin, + 'application/octet-stream', + ) + expect(uploadId).toBeDefined() + expect(typeof uploadId).toBe('string') + expect(uploadId.length).toBeGreaterThan(0) + + // cleanup + await s3client.abortMultipartUpload(key, uploadId) + }) + + // test uploadPart + + it('uploadPart returns partNumber and Etag', async () => { + const partData = randomBytes(EIGHT_MB) + + const { uploadId } = await s3client.createMultipartUpload( + key_bin, + 'application/octet-stream', + ) + + // upload part + const partResult = await s3client.uploadPart( + key_bin, + uploadId, + partData, + 1, + ) + + expect(partResult).toBeDefined() + expect(partResult.etag).toBeDefined() + expect(typeof partResult.etag).toBe('string') + expect(partResult.etag.length).toBe(32) + + // cleanup + await s3client.abortMultipartUpload(key, uploadId) + }) + + // end to end multipart flow + + it('completeMultipartUpload assembles parts correctly', async () => { + // key - key_bin + const partSize = EIGHT_MB + const totalParts = Math.ceil(large_buffer.byteLength / partSize) + + const { uploadId } = await s3client.createMultipartUpload( + key_bin, + 'application/octet-stream', + ) + expect(uploadId).toBeDefined() + + // upload all parts + const uploadPromises: Promise<{ etag: string }>[] = [] + for (let i = 0; i < totalParts; i++) { + const partBuffer = large_buffer.subarray( + i * partSize, + (i + 1) * partSize, + ) + uploadPromises.push( + s3client.uploadPart(key_bin, uploadId, partBuffer, i + 1), + ) + } + const uploadResponses = await Promise.all(uploadPromises) + + // verify all parts uploaded succesfully + + expect(uploadResponses.length).toBe(totalParts) + + uploadResponses.forEach((response) => { + expect(response.etag).toBeDefined() + }) + + // create multipart upload + + const parts = uploadResponses.map((response, index) => ({ + partNumber: index + 1, + etag: response.etag, + })) + + const completeResponse = await s3client.completeMultipartUpload( + key_bin, + uploadId, + parts, + ) + + expect(completeResponse).toBeDefined() + expect(typeof completeResponse).toBe('object') + expect(completeResponse.etag).toBeDefined() + expect(typeof completeResponse.etag).toBe('string') + expect(completeResponse.etag!.length).toBe(32 + 2) + + // cleanup + + await s3client.deleteObject(key_bin) + }) + + it('abortMultipartUpload cancels upload successfully', async () => { + // start upload + const { uploadId } = await s3client.createMultipartUpload( + key_abort_multipart, + 'application/octet-stream', + ) + + expect(uploadId).toBeDefined() + + const partData = randomBytes(EIGHT_MB) + await s3client.uploadPart(key_abort_multipart, uploadId, partData, 1) + + // abort + await s3client.abortMultipartUpload(key_abort_multipart, uploadId) + }) + + it('listParts returns uploaded parts correctly', async () => { + const partSize = EIGHT_MB + + const { uploadId } = await s3client.createMultipartUpload( + key_list_parts, + 'application/octet-stream', + ) + expect(uploadId).toBeDefined() + + const part1Data = randomBytes(partSize) + const part2Data = randomBytes(partSize) + + const part1Result = await s3client.uploadPart( + key_list_parts, + uploadId, + part1Data, + 1, + ) + const part2Result = await s3client.uploadPart( + key_list_parts, + uploadId, + part2Data, + 2, + ) + + const parts = await s3client.listParts(uploadId, key_list_parts) + + expect(parts).toBeInstanceOf(Array) + expect(parts.length).toBe(2) + + // verify part 1 + expect(parts[0].partNumber).toBe(1) + expect(parts[0].etag).toBe(part1Result.etag) + + // verify part 2 + expect(parts[1].partNumber).toBe(2) + expect(parts[1].etag).toBe(part2Result.etag) + + // cleanup abort upload + await s3client.abortMultipartUpload(key, uploadId) + }) + }) +} else { + console.warn( + 'Skipping MinIO S3 client tests: missing env variable VITE_MINIO_CONFIG', + ) + describe.skip(suiteName, () => { + it('noop', () => {}) + }) +} diff --git a/packages/@uppy/aws-s3/tests/s3-client/setup.ts b/packages/@uppy/aws-s3/tests/s3-client/setup.ts new file mode 100644 index 0000000000..2f52e586df --- /dev/null +++ b/packages/@uppy/aws-s3/tests/s3-client/setup.ts @@ -0,0 +1,48 @@ +import path from 'node:path' +import { getConfig } from './config' +import { composeDown, composeUpWait, execDockerCommand } from './docker' + +const composeFile = path.join(__dirname, 'compose.minio.yaml') + +const config = getConfig(process.env) + +export async function setup() { + if (config == null) return + + const { endpoint, rootAccessKeyId, rootSecretAccessKey } = config + + console.log('⏫ starting minio image', composeFile) + process.env.MINIO_ROOT_USER = rootAccessKeyId + process.env.MINIO_ROOT_PASSWORD = rootSecretAccessKey + await composeUpWait(composeFile) + const bucketName = new URL(endpoint).pathname.split('/')[1] + await execDockerCommand( + 'minio', + `mc mb local/${bucketName} --ignore-existing`, + 30000, + ) + // Create a user with readwrite policy for STS testing + // The root user cannot AssumeRole for itself - needs a regular user + try { + await execDockerCommand( + 'minio', + `mc admin user add local stsuser stspassword123`, + ) + await execDockerCommand( + 'minio', + `mc admin policy attach local readwrite --user stsuser`, + ) + } catch (err) { + // User may already exist, that's fine + console.log('STS user setup:', err.message || 'error occurred') + } + console.log(`✅ minio is ready`) +} + +export async function teardown() { + if (config == null) return + + console.log(`⏬ stopping minio image …`) + await composeDown(composeFile) + console.log(`✅ minio stopped and cleaned up`) +} diff --git a/packages/@uppy/aws-s3/tests/test-utils/browser-crypto.ts b/packages/@uppy/aws-s3/tests/test-utils/browser-crypto.ts new file mode 100644 index 0000000000..2aa5aee660 --- /dev/null +++ b/packages/@uppy/aws-s3/tests/test-utils/browser-crypto.ts @@ -0,0 +1,17 @@ +/** + * Generate random bytes using Web Crypto API. + * Web Crypto has a 65536 byte limit per getRandomValues call, + * so we chunk large requests. + */ +export function randomBytes(size: number) { + const buffer = new Uint8Array(size) + const maxChunk = 65536 // Web Crypto API limit + + for (let offset = 0; offset < size; offset += maxChunk) { + const chunkSize = Math.min(maxChunk, size - offset) + const chunk = buffer.subarray(offset, offset + chunkSize) + globalThis.crypto.getRandomValues(chunk) + } + + return buffer +} diff --git a/packages/@uppy/aws-s3/vitest.config.ts b/packages/@uppy/aws-s3/vitest.config.ts index 3544b2562c..37b4bd06ab 100644 --- a/packages/@uppy/aws-s3/vitest.config.ts +++ b/packages/@uppy/aws-s3/vitest.config.ts @@ -2,6 +2,27 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - setupFiles: ['./test-setup.mjs'], + testTimeout: 120_000, + globalSetup: ['tests/s3-client/setup.ts'], + projects: [ + { + test: { + name: 's3-jsdom', + include: ['**/*.test.ts'], + environment: 'jsdom', + }, + }, + { + test: { + name: 's3-browser', + include: ['**/minio.test.ts'], + browser: { + enabled: true, + provider: 'playwright', + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], }, }) diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index 6f13065b25..5ced71801c 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -662,11 +662,12 @@ export class Uppy< ...files[fileID].progress, ...defaultProgress, }, - // @ts-expect-error these typed are inserted + // @ts-expect-error these types are inserted // into the namespace in their respective packages - // but core isn't ware of those + // but core isn't aware of those tus: undefined, transloadit: undefined, + s3Multipart: undefined, } }) diff --git a/packages/@uppy/utils/src/fetcher.ts b/packages/@uppy/utils/src/fetcher.ts index 87e0540dba..c550aecef9 100644 --- a/packages/@uppy/utils/src/fetcher.ts +++ b/packages/@uppy/utils/src/fetcher.ts @@ -61,7 +61,7 @@ export function fetcher( options: FetcherOptions = {}, ): Promise { const { - body = null, + body, headers = {}, method = 'GET', onBeforeRequest = noop, diff --git a/private/dev/Dashboard.js b/private/dev/Dashboard.js index f3b7ffdd51..16b73f68af 100644 --- a/private/dev/Dashboard.js +++ b/private/dev/Dashboard.js @@ -208,13 +208,13 @@ export default () => { break case 's3': uppyDashboard.use(AwsS3, { - endpoint: COMPANION_URL, + companionEndpoint: COMPANION_URL, shouldUseMultipart: false, }) break case 's3-multipart': uppyDashboard.use(AwsS3, { - endpoint: COMPANION_URL, + companionEndpoint: COMPANION_URL, shouldUseMultipart: true, }) break diff --git a/turbo.json b/turbo.json index 687e96dd81..a3386bfe39 100644 --- a/turbo.json +++ b/turbo.json @@ -41,7 +41,8 @@ }, "test": { "dependsOn": ["^test"], - "cache": false + "cache": false, + "passThroughEnv": ["VITE_MINIO_CONFIG"] }, "test:e2e": { "dependsOn": ["^test:e2e"], diff --git a/yarn.lock b/yarn.lock index 38952d48cb..c66547166e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -831,6 +831,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/client-sso@npm:3.953.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/middleware-host-header": "npm:3.953.0" + "@aws-sdk/middleware-logger": "npm:3.953.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.953.0" + "@aws-sdk/middleware-user-agent": "npm:3.953.0" + "@aws-sdk/region-config-resolver": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@aws-sdk/util-endpoints": "npm:3.953.0" + "@aws-sdk/util-user-agent-browser": "npm:3.953.0" + "@aws-sdk/util-user-agent-node": "npm:3.953.0" + "@smithy/config-resolver": "npm:^4.4.4" + "@smithy/core": "npm:^3.19.0" + "@smithy/fetch-http-handler": "npm:^5.3.7" + "@smithy/hash-node": "npm:^4.2.6" + "@smithy/invalid-dependency": "npm:^4.2.6" + "@smithy/middleware-content-length": "npm:^4.2.6" + "@smithy/middleware-endpoint": "npm:^4.3.15" + "@smithy/middleware-retry": "npm:^4.4.15" + "@smithy/middleware-serde": "npm:^4.2.7" + "@smithy/middleware-stack": "npm:^4.2.6" + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/node-http-handler": "npm:^4.4.6" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/smithy-client": "npm:^4.10.0" + "@smithy/types": "npm:^4.10.0" + "@smithy/url-parser": "npm:^4.2.6" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-body-length-browser": "npm:^4.2.0" + "@smithy/util-body-length-node": "npm:^4.2.1" + "@smithy/util-defaults-mode-browser": "npm:^4.3.14" + "@smithy/util-defaults-mode-node": "npm:^4.2.17" + "@smithy/util-endpoints": "npm:^3.2.6" + "@smithy/util-middleware": "npm:^4.2.6" + "@smithy/util-retry": "npm:^4.2.6" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/f7b21d79a608e9ab72b6ae1bbc8adb05a0659825d0f5ca2548368cff911c94502209fdebd0637306ec8923cb0ecbf21f238ee69191363bd416b36252343966a6 + languageName: node + linkType: hard + "@aws-sdk/client-sts@npm:3.600.0, @aws-sdk/client-sts@npm:^3.338.0": version: 3.600.0 resolution: "@aws-sdk/client-sts@npm:3.600.0" @@ -879,6 +925,53 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sts@npm:^3.362.0": + version: 3.953.0 + resolution: "@aws-sdk/client-sts@npm:3.953.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/credential-provider-node": "npm:3.953.0" + "@aws-sdk/middleware-host-header": "npm:3.953.0" + "@aws-sdk/middleware-logger": "npm:3.953.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.953.0" + "@aws-sdk/middleware-user-agent": "npm:3.953.0" + "@aws-sdk/region-config-resolver": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@aws-sdk/util-endpoints": "npm:3.953.0" + "@aws-sdk/util-user-agent-browser": "npm:3.953.0" + "@aws-sdk/util-user-agent-node": "npm:3.953.0" + "@smithy/config-resolver": "npm:^4.4.4" + "@smithy/core": "npm:^3.19.0" + "@smithy/fetch-http-handler": "npm:^5.3.7" + "@smithy/hash-node": "npm:^4.2.6" + "@smithy/invalid-dependency": "npm:^4.2.6" + "@smithy/middleware-content-length": "npm:^4.2.6" + "@smithy/middleware-endpoint": "npm:^4.3.15" + "@smithy/middleware-retry": "npm:^4.4.15" + "@smithy/middleware-serde": "npm:^4.2.7" + "@smithy/middleware-stack": "npm:^4.2.6" + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/node-http-handler": "npm:^4.4.6" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/smithy-client": "npm:^4.10.0" + "@smithy/types": "npm:^4.10.0" + "@smithy/url-parser": "npm:^4.2.6" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-body-length-browser": "npm:^4.2.0" + "@smithy/util-body-length-node": "npm:^4.2.1" + "@smithy/util-defaults-mode-browser": "npm:^4.3.14" + "@smithy/util-defaults-mode-node": "npm:^4.2.17" + "@smithy/util-endpoints": "npm:^3.2.6" + "@smithy/util-middleware": "npm:^4.2.6" + "@smithy/util-retry": "npm:^4.2.6" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/b41dc2190fec8ccd2bcae77a2cfc1ed9d20e9586c4bdfa60274fa1f4e11272bc78b01c493d25f492d44c132c20a8af70d4faa36f49f3559f2b6f7dc7101f6d0d + languageName: node + linkType: hard + "@aws-sdk/core@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/core@npm:3.598.0" @@ -915,6 +1008,27 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/core@npm:3.953.0" + dependencies: + "@aws-sdk/types": "npm:3.953.0" + "@aws-sdk/xml-builder": "npm:3.953.0" + "@smithy/core": "npm:^3.19.0" + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/signature-v4": "npm:^5.3.6" + "@smithy/smithy-client": "npm:^4.10.0" + "@smithy/types": "npm:^4.10.0" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-middleware": "npm:^4.2.6" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/949798b03a10e309bf7dc6bfc2bfb4aabaddb6dee87c85e06c811b933af1cd83ec989be96c8066201663d50b48c40a82db9f543ababc1b43f885d6ce171fe7d5 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-env@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-env@npm:3.598.0" @@ -940,6 +1054,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.953.0" + dependencies: + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/82c006cf8944476fe857b8b61533f25cca3ffbe9dfe236cc26dc88c4d54a2d22b23cb16faddfa130b1f5bd5ed0c0ba8bca04c67e5bff11d278f772057b6c4b4c + languageName: node + linkType: hard + "@aws-sdk/credential-provider-http@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-http@npm:3.598.0" @@ -975,6 +1102,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-http@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/credential-provider-http@npm:3.953.0" + dependencies: + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/fetch-http-handler": "npm:^5.3.7" + "@smithy/node-http-handler": "npm:^4.4.6" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/smithy-client": "npm:^4.10.0" + "@smithy/types": "npm:^4.10.0" + "@smithy/util-stream": "npm:^4.5.7" + tslib: "npm:^2.6.2" + checksum: 10/0a4c0092929caac95fd2c003c504bba26e5aa884252a98de7a03f18cb1ef69e944f4997628f7409ed7fb364d412a5d8c64ef8e9e8fc057612cc10314e66b45a2 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-ini@npm:3.598.0" @@ -1017,6 +1162,44 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.953.0" + dependencies: + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/credential-provider-env": "npm:3.953.0" + "@aws-sdk/credential-provider-http": "npm:3.953.0" + "@aws-sdk/credential-provider-login": "npm:3.953.0" + "@aws-sdk/credential-provider-process": "npm:3.953.0" + "@aws-sdk/credential-provider-sso": "npm:3.953.0" + "@aws-sdk/credential-provider-web-identity": "npm:3.953.0" + "@aws-sdk/nested-clients": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/credential-provider-imds": "npm:^4.2.6" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/shared-ini-file-loader": "npm:^4.4.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/e416b9e2d2da37f945450282a78e9b477daf6e3dafcb7c61b25897c4438ce7b4675ea6cc9292bb120a4ba16a0105ad51f62d9af0b00007275a3c14a3d3f57890 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-login@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/credential-provider-login@npm:3.953.0" + dependencies: + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/nested-clients": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/shared-ini-file-loader": "npm:^4.4.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/0642d2029f8dbadd95e1678afd4a9a99735028b06934cd783b91acad86e29fb920bd75396f95b6a581ad8cb4cee36444b3aef37d8721ecdd7d47147f7abdd182 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:3.600.0": version: 3.600.0 resolution: "@aws-sdk/credential-provider-node@npm:3.600.0" @@ -1057,6 +1240,26 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.953.0" + dependencies: + "@aws-sdk/credential-provider-env": "npm:3.953.0" + "@aws-sdk/credential-provider-http": "npm:3.953.0" + "@aws-sdk/credential-provider-ini": "npm:3.953.0" + "@aws-sdk/credential-provider-process": "npm:3.953.0" + "@aws-sdk/credential-provider-sso": "npm:3.953.0" + "@aws-sdk/credential-provider-web-identity": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/credential-provider-imds": "npm:^4.2.6" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/shared-ini-file-loader": "npm:^4.4.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/a842444887a1ea8ccf6282b34a150f22c75274650906bfde2cae76e672fd3898c0504c8add5459c0cd8a1113c397791d9dad77dce427282429c998435a4629c4 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-process@npm:3.598.0" @@ -1084,6 +1287,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.953.0" + dependencies: + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/shared-ini-file-loader": "npm:^4.4.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/39ff89306d9a55becec4d5fa8dc82420c3cd5ff5079fb91ec2b8812cf048d6063474215dd2a205e8f02f85815b17b38f89ed7f5a1b16023bfcd56abeb325dd55 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-sso@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-sso@npm:3.598.0" @@ -1115,6 +1332,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.953.0" + dependencies: + "@aws-sdk/client-sso": "npm:3.953.0" + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/token-providers": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/shared-ini-file-loader": "npm:^4.4.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/4c95ce8f60983d31a7454ee0a201dea4c0e20e9e2ce4ef1eebf6e096f4cb99991811260a47cb2c9ed2a0b63c5a1663f7b4c7c9d19918b2494239e54efab8477f + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.598.0" @@ -1144,6 +1377,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.953.0" + dependencies: + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/nested-clients": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/shared-ini-file-loader": "npm:^4.4.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/84d94fb4ecfa12b882f49a409b9cdc3c17de08f94b76376b64690198bc61e3ec2d0e85d2e2518f3bb99ce7cb43fc32202f6c193f89b0df9c03fc586f3e6df560 + languageName: node + linkType: hard + "@aws-sdk/lib-storage@npm:^3.338.0": version: 3.600.0 resolution: "@aws-sdk/lib-storage@npm:3.600.0" @@ -1276,6 +1524,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-host-header@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/middleware-host-header@npm:3.953.0" + dependencies: + "@aws-sdk/types": "npm:3.953.0" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/c78936083a336310814d7e64ffd8d6dbf26b85abd4f8bcf90b75a1ad629704e390d75493339082d38a96c24a9ab9b1ad6e8e22d87035486547a3ae32fbdc13e4 + languageName: node + linkType: hard + "@aws-sdk/middleware-location-constraint@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/middleware-location-constraint@npm:3.598.0" @@ -1320,6 +1580,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-logger@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/middleware-logger@npm:3.953.0" + dependencies: + "@aws-sdk/types": "npm:3.953.0" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/3f02f3a954af46a94246e6bdbf5aca351bd61d6fc0c04f83505be353da128af26ac4ef9190450766bb6ea298213056c5dbc64e641a366a318e394e7272edf4b0 + languageName: node + linkType: hard + "@aws-sdk/middleware-recursion-detection@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/middleware-recursion-detection@npm:3.598.0" @@ -1345,6 +1616,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-recursion-detection@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.953.0" + dependencies: + "@aws-sdk/types": "npm:3.953.0" + "@aws/lambda-invoke-store": "npm:^0.2.2" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/02802b74a08cbf8731db588c01645acf0bd9e014f3e68e7690fc28d62348a498da76f68a47a763b8d7f4a7e407d23c7c9e6f98017f85dde853b481421556a451 + languageName: node + linkType: hard + "@aws-sdk/middleware-sdk-s3@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/middleware-sdk-s3@npm:3.598.0" @@ -1449,6 +1733,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-user-agent@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.953.0" + dependencies: + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@aws-sdk/util-endpoints": "npm:3.953.0" + "@smithy/core": "npm:^3.19.0" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/2dfccca22e3829c17b609cca236fe3be2e2bc234c438d0beda8d87cd7614a8b31825a63337ce26b565d52c3905e4c6e914d4f83156654bededb6f175fa9880ac + languageName: node + linkType: hard + "@aws-sdk/nested-clients@npm:3.896.0": version: 3.896.0 resolution: "@aws-sdk/nested-clients@npm:3.896.0" @@ -1495,6 +1794,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/nested-clients@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/nested-clients@npm:3.953.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/middleware-host-header": "npm:3.953.0" + "@aws-sdk/middleware-logger": "npm:3.953.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.953.0" + "@aws-sdk/middleware-user-agent": "npm:3.953.0" + "@aws-sdk/region-config-resolver": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@aws-sdk/util-endpoints": "npm:3.953.0" + "@aws-sdk/util-user-agent-browser": "npm:3.953.0" + "@aws-sdk/util-user-agent-node": "npm:3.953.0" + "@smithy/config-resolver": "npm:^4.4.4" + "@smithy/core": "npm:^3.19.0" + "@smithy/fetch-http-handler": "npm:^5.3.7" + "@smithy/hash-node": "npm:^4.2.6" + "@smithy/invalid-dependency": "npm:^4.2.6" + "@smithy/middleware-content-length": "npm:^4.2.6" + "@smithy/middleware-endpoint": "npm:^4.3.15" + "@smithy/middleware-retry": "npm:^4.4.15" + "@smithy/middleware-serde": "npm:^4.2.7" + "@smithy/middleware-stack": "npm:^4.2.6" + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/node-http-handler": "npm:^4.4.6" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/smithy-client": "npm:^4.10.0" + "@smithy/types": "npm:^4.10.0" + "@smithy/url-parser": "npm:^4.2.6" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-body-length-browser": "npm:^4.2.0" + "@smithy/util-body-length-node": "npm:^4.2.1" + "@smithy/util-defaults-mode-browser": "npm:^4.3.14" + "@smithy/util-defaults-mode-node": "npm:^4.2.17" + "@smithy/util-endpoints": "npm:^3.2.6" + "@smithy/util-middleware": "npm:^4.2.6" + "@smithy/util-retry": "npm:^4.2.6" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/35baacf1c2513515f0ae6b1f3772401f43a24eaac08c1614680711aae23418ba1243a3144419a4a0662e929d2411ac39f833984794b92fdc4aa8238ee2ce7020 + languageName: node + linkType: hard + "@aws-sdk/region-config-resolver@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/region-config-resolver@npm:3.598.0" @@ -1523,6 +1868,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/region-config-resolver@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/region-config-resolver@npm:3.953.0" + dependencies: + "@aws-sdk/types": "npm:3.953.0" + "@smithy/config-resolver": "npm:^4.4.4" + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/b11d9507df620afceec0e1a6c39225eae0494aac602c0113a895cff8cec2a6db36523ad8eff4b673f1d15a6e5bc9c971ba0c64d46ff5a59df5afa2b198e3f969 + languageName: node + linkType: hard + "@aws-sdk/s3-presigned-post@npm:^3.338.0": version: 3.600.0 resolution: "@aws-sdk/s3-presigned-post@npm:3.600.0" @@ -1614,6 +1972,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/token-providers@npm:3.953.0" + dependencies: + "@aws-sdk/core": "npm:3.953.0" + "@aws-sdk/nested-clients": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/shared-ini-file-loader": "npm:^4.4.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/1ef0b8d8da84f815ef8ce9789fb894d512d970b91a61317422829ea82faebfc0d7270581306554e19b9f70e39730e56a438c25bffd29cdb4bdbecbb821caa53c + languageName: node + linkType: hard + "@aws-sdk/types@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/types@npm:3.598.0" @@ -1634,6 +2007,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/types@npm:3.953.0" + dependencies: + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/959bab8a9cd280c0824265dce0892796326896df5407d4cf482791a76033182fe10b8080909965610cb56ff6c6117413e9920e864f519e85ce2c5a0041fd9cf1 + languageName: node + linkType: hard + "@aws-sdk/util-arn-parser@npm:3.568.0": version: 3.568.0 resolution: "@aws-sdk/util-arn-parser@npm:3.568.0" @@ -1677,6 +2060,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-endpoints@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/util-endpoints@npm:3.953.0" + dependencies: + "@aws-sdk/types": "npm:3.953.0" + "@smithy/types": "npm:^4.10.0" + "@smithy/url-parser": "npm:^4.2.6" + "@smithy/util-endpoints": "npm:^3.2.6" + tslib: "npm:^2.6.2" + checksum: 10/d1aebcf2a32d0c40c28462fcfaa34868fb09f7560ce622bda6f5194039783be218134119e928e4162e2820abfa7cf2777b730fbca6b79a64cb98f4763dc113eb + languageName: node + linkType: hard + "@aws-sdk/util-format-url@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/util-format-url@npm:3.598.0" @@ -1734,6 +2130,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-browser@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.953.0" + dependencies: + "@aws-sdk/types": "npm:3.953.0" + "@smithy/types": "npm:^4.10.0" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10/735ea58521b095ac5546e8cab4eadbb944207a619ed47a5ef962b6501bfc8dc296d24e3c7a2c27a0a44537966a49bf61467ed4bf5578eb8ba07fa6f343e99063 + languageName: node + linkType: hard + "@aws-sdk/util-user-agent-node@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/util-user-agent-node@npm:3.598.0" @@ -1769,6 +2177,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-node@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.953.0" + dependencies: + "@aws-sdk/middleware-user-agent": "npm:3.953.0" + "@aws-sdk/types": "npm:3.953.0" + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 10/8d21a0106214469f1ce72b33cbdf3fef2dcdf36505874b9c0a999bee1b63390190e82f496c5b1e9cd0503304cee9ec58c491e5663cc38b3965284b74e9e0757f + languageName: node + linkType: hard + "@aws-sdk/xml-builder@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/xml-builder@npm:3.598.0" @@ -1790,6 +2216,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/xml-builder@npm:3.953.0": + version: 3.953.0 + resolution: "@aws-sdk/xml-builder@npm:3.953.0" + dependencies: + "@smithy/types": "npm:^4.10.0" + fast-xml-parser: "npm:5.2.5" + tslib: "npm:^2.6.2" + checksum: 10/7ab2023a397373f4c52501e178d054d7637df78a90dc5ee430af6448e005f601f230aa284f8b41371a2db5a382c568a076968437bf1ec4b0122f546ea1e14b7e + languageName: node + linkType: hard + "@aws/lambda-invoke-store@npm:^0.0.1": version: 0.0.1 resolution: "@aws/lambda-invoke-store@npm:0.0.1" @@ -1797,6 +2234,13 @@ __metadata: languageName: node linkType: hard +"@aws/lambda-invoke-store@npm:^0.2.2": + version: 0.2.2 + resolution: "@aws/lambda-invoke-store@npm:0.2.2" + checksum: 10/18cd0cec90d9d865c9089218ef2220b0a7302a860c9a3f808b101386f569abc5ee11eb98a36947bed280a63308dd5df23c39e7b07fe9ac4f4ffcd0c4dce537c4 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -6047,6 +6491,16 @@ __metadata: languageName: node linkType: hard +"@smithy/abort-controller@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/abort-controller@npm:4.2.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/ef683909987f6225e43de1a256126f20d0ed213ae4f59edb2d9bc4c0c0d3d809dc66d3313afc3603aae81b842a23aa47f6e19d6439f009d7898db8c4206a7588 + languageName: node + linkType: hard + "@smithy/chunked-blob-reader-native@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/chunked-blob-reader-native@npm:3.0.0" @@ -6111,6 +6565,20 @@ __metadata: languageName: node linkType: hard +"@smithy/config-resolver@npm:^4.4.4": + version: 4.4.4 + resolution: "@smithy/config-resolver@npm:4.4.4" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/types": "npm:^4.10.0" + "@smithy/util-config-provider": "npm:^4.2.0" + "@smithy/util-endpoints": "npm:^3.2.6" + "@smithy/util-middleware": "npm:^4.2.6" + tslib: "npm:^2.6.2" + checksum: 10/61f25f55e81233b7cd7cb3a123bb16052640de63ef6badab399b0f95fe3846e36b93181c6d80707265c662f0949a2f7f12b57e85580ed3a0e43403b19c87b36f + languageName: node + linkType: hard + "@smithy/core@npm:^2.2.1": version: 2.2.3 resolution: "@smithy/core@npm:2.2.3" @@ -6145,6 +6613,24 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.19.0": + version: 3.19.0 + resolution: "@smithy/core@npm:3.19.0" + dependencies: + "@smithy/middleware-serde": "npm:^4.2.7" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/types": "npm:^4.10.0" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-body-length-browser": "npm:^4.2.0" + "@smithy/util-middleware": "npm:^4.2.6" + "@smithy/util-stream": "npm:^4.5.7" + "@smithy/util-utf8": "npm:^4.2.0" + "@smithy/uuid": "npm:^1.1.0" + tslib: "npm:^2.6.2" + checksum: 10/feedf2138fd1b63108603d30969d9bc9053f117086f690f26049193381af8ba78a48fc90d8941c98c3564c2f45dc8ad57fb6bae9cff0a146f6c82d7b376def02 + languageName: node + linkType: hard + "@smithy/credential-provider-imds@npm:^3.1.1, @smithy/credential-provider-imds@npm:^3.1.2": version: 3.1.2 resolution: "@smithy/credential-provider-imds@npm:3.1.2" @@ -6171,6 +6657,19 @@ __metadata: languageName: node linkType: hard +"@smithy/credential-provider-imds@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/credential-provider-imds@npm:4.2.6" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/types": "npm:^4.10.0" + "@smithy/url-parser": "npm:^4.2.6" + tslib: "npm:^2.6.2" + checksum: 10/ac0fe1e1bb75b82287bdd6869f072ce9928baaf27d61cba7c881968161dd94fd9c287f208ac7b77c83a7983bde9cfffd5bcc32ba788dbd6903aa3702aac1618a + languageName: node + linkType: hard + "@smithy/eventstream-codec@npm:^3.1.1": version: 3.1.1 resolution: "@smithy/eventstream-codec@npm:3.1.1" @@ -6307,6 +6806,19 @@ __metadata: languageName: node linkType: hard +"@smithy/fetch-http-handler@npm:^5.3.7": + version: 5.3.7 + resolution: "@smithy/fetch-http-handler@npm:5.3.7" + dependencies: + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/querystring-builder": "npm:^4.2.6" + "@smithy/types": "npm:^4.10.0" + "@smithy/util-base64": "npm:^4.3.0" + tslib: "npm:^2.6.2" + checksum: 10/ad45a381f37dcad2e7df47b596569bd182e96f73ca329726bb10209eff7e92efb1d50e3a0f14ffc15c7d6e0afbc8cc267c2151e6b2f99df33fa1dbc95441c0a9 + languageName: node + linkType: hard + "@smithy/hash-blob-browser@npm:^3.1.0": version: 3.1.1 resolution: "@smithy/hash-blob-browser@npm:3.1.1" @@ -6355,6 +6867,18 @@ __metadata: languageName: node linkType: hard +"@smithy/hash-node@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/hash-node@npm:4.2.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + "@smithy/util-buffer-from": "npm:^4.2.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/ae593b814d1cbd12a676d246ab0528a9f1b79c698c403b88255b20116c256c815b6fbbd3bfda496f5205159bdcd0fc69b75224837767981b19f43270b846e58e + languageName: node + linkType: hard + "@smithy/hash-stream-node@npm:^3.1.0": version: 3.1.1 resolution: "@smithy/hash-stream-node@npm:3.1.1" @@ -6397,6 +6921,16 @@ __metadata: languageName: node linkType: hard +"@smithy/invalid-dependency@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/invalid-dependency@npm:4.2.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/96a2863eaa1405e4bd38521f159f78429e6a329a4606034630d161ba3206a1574c76f5b1099357928b9a9b2987e180f3ca51b3c4e917502c6931f7aaa666b9c9 + languageName: node + linkType: hard + "@smithy/is-array-buffer@npm:^2.2.0": version: 2.2.0 resolution: "@smithy/is-array-buffer@npm:2.2.0" @@ -6424,6 +6958,15 @@ __metadata: languageName: node linkType: hard +"@smithy/is-array-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/is-array-buffer@npm:4.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/fdc097ce6a8b241565e2d56460ec289730bcd734dcde17c23d1eaaa0996337f897217166276a3fd82491fe9fd17447aadf62e8d9056b3d2b9daf192b4b668af9 + languageName: node + linkType: hard + "@smithy/md5-js@npm:^3.0.1": version: 3.0.2 resolution: "@smithy/md5-js@npm:3.0.2" @@ -6468,6 +7011,17 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-content-length@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/middleware-content-length@npm:4.2.6" + dependencies: + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/224d5c5c3aa0f8d0d9a934b2c82b77ba9e0b9fdd27502adaa011f8d5c3f521e501763f377546b0945908d947b4e74fabc00d4fc282a1d3dc976339ae4fdc4945 + languageName: node + linkType: hard + "@smithy/middleware-endpoint@npm:^3.0.2, @smithy/middleware-endpoint@npm:^3.0.3": version: 3.0.3 resolution: "@smithy/middleware-endpoint@npm:3.0.3" @@ -6499,6 +7053,22 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-endpoint@npm:^4.3.15, @smithy/middleware-endpoint@npm:^4.4.0": + version: 4.4.0 + resolution: "@smithy/middleware-endpoint@npm:4.4.0" + dependencies: + "@smithy/core": "npm:^3.19.0" + "@smithy/middleware-serde": "npm:^4.2.7" + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/shared-ini-file-loader": "npm:^4.4.1" + "@smithy/types": "npm:^4.10.0" + "@smithy/url-parser": "npm:^4.2.6" + "@smithy/util-middleware": "npm:^4.2.6" + tslib: "npm:^2.6.2" + checksum: 10/3b6c3cfa39daa27c27c10054658912eb9f4db19371461821f0fb9afeda50e97fe3af2cbe46d3c481dea63161788895217fffe1484d9cae716364385dac317bcb + languageName: node + linkType: hard + "@smithy/middleware-retry@npm:^3.0.4, @smithy/middleware-retry@npm:^3.0.6": version: 3.0.6 resolution: "@smithy/middleware-retry@npm:3.0.6" @@ -6533,6 +7103,23 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-retry@npm:^4.4.15": + version: 4.4.16 + resolution: "@smithy/middleware-retry@npm:4.4.16" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/service-error-classification": "npm:^4.2.6" + "@smithy/smithy-client": "npm:^4.10.1" + "@smithy/types": "npm:^4.10.0" + "@smithy/util-middleware": "npm:^4.2.6" + "@smithy/util-retry": "npm:^4.2.6" + "@smithy/uuid": "npm:^1.1.0" + tslib: "npm:^2.6.2" + checksum: 10/b2d873d535eea2af1d719594a900167bf6e5b760492745ea04e9d85b1d9248944ac46c18a8595a27f4ea43d2bad92ed04d822e463ac1d12b248a07a7702a07ac + languageName: node + linkType: hard + "@smithy/middleware-serde@npm:^3.0.1, @smithy/middleware-serde@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/middleware-serde@npm:3.0.2" @@ -6554,6 +7141,17 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-serde@npm:^4.2.7": + version: 4.2.7 + resolution: "@smithy/middleware-serde@npm:4.2.7" + dependencies: + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/5cc20397a0ebd657825b8694599d1af5e98adb6f2232073bdd1045a3af9ecf62cfde3ac90613c94836b543b2fbff4f0be212410052089a66e54bd52638b61693 + languageName: node + linkType: hard + "@smithy/middleware-stack@npm:^3.0.1, @smithy/middleware-stack@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/middleware-stack@npm:3.0.2" @@ -6574,6 +7172,16 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-stack@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/middleware-stack@npm:4.2.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/17f5247494caa3325375f2ff729844816ccb6f7c4acf3033dba448fe85172290f5dc4cebefabcfd9c238ba84b2eef5a4d5f689cfcff39913621feb33645ddd3b + languageName: node + linkType: hard + "@smithy/node-config-provider@npm:^3.1.1, @smithy/node-config-provider@npm:^3.1.2": version: 3.1.2 resolution: "@smithy/node-config-provider@npm:3.1.2" @@ -6598,6 +7206,18 @@ __metadata: languageName: node linkType: hard +"@smithy/node-config-provider@npm:^4.3.6": + version: 4.3.6 + resolution: "@smithy/node-config-provider@npm:4.3.6" + dependencies: + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/shared-ini-file-loader": "npm:^4.4.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/f67ebc9f50d7e44295b6ba5ca0341b54b4fb472d85925993778ef4e3c9046146a15d01b61b31928fa23465a46974cd69b851d3e529beb6da5f4b13e4fb50fe29 + languageName: node + linkType: hard + "@smithy/node-http-handler@npm:^3.0.1, @smithy/node-http-handler@npm:^3.1.0": version: 3.1.0 resolution: "@smithy/node-http-handler@npm:3.1.0" @@ -6624,6 +7244,19 @@ __metadata: languageName: node linkType: hard +"@smithy/node-http-handler@npm:^4.4.6": + version: 4.4.6 + resolution: "@smithy/node-http-handler@npm:4.4.6" + dependencies: + "@smithy/abort-controller": "npm:^4.2.6" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/querystring-builder": "npm:^4.2.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/52afe7ee82b2e988dcd2c2ce6bcd0bc4f7aeaa7a517d722a40b9237841a586ced381b27c3d73b9dc573f5bd6168742427a155103da8474d268b5af0194fc734b + languageName: node + linkType: hard + "@smithy/property-provider@npm:^3.1.1, @smithy/property-provider@npm:^3.1.2": version: 3.1.2 resolution: "@smithy/property-provider@npm:3.1.2" @@ -6644,6 +7277,16 @@ __metadata: languageName: node linkType: hard +"@smithy/property-provider@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/property-provider@npm:4.2.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/019f03694bd0c23a61e62c85271b612267886dd6b631ea8b3c79038edefdcb05136988bfb3ae86dca43868cb072545420d4dbaaea012c626107d168f8349a211 + languageName: node + linkType: hard + "@smithy/protocol-http@npm:^4.0.1, @smithy/protocol-http@npm:^4.0.2": version: 4.0.2 resolution: "@smithy/protocol-http@npm:4.0.2" @@ -6664,6 +7307,16 @@ __metadata: languageName: node linkType: hard +"@smithy/protocol-http@npm:^5.3.6": + version: 5.3.6 + resolution: "@smithy/protocol-http@npm:5.3.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/a7fe130953bcc7bfd1a51cb65443919003aa6954950489023f33355a55e096287ae10760dd2b6f4359666c3e99ad584a12422a7a31fffc4b171c35b85b16a769 + languageName: node + linkType: hard + "@smithy/querystring-builder@npm:^3.0.1, @smithy/querystring-builder@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/querystring-builder@npm:3.0.2" @@ -6686,6 +7339,17 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-builder@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/querystring-builder@npm:4.2.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + "@smithy/util-uri-escape": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/2495004856d76c3de3b8df793a3e53233f2e993e34bb936f120aeaa74712acee03ef9b828457a8fa7ed616d01947b357f898aff37519aba80d200ed8104843bd + languageName: node + linkType: hard + "@smithy/querystring-parser@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/querystring-parser@npm:3.0.2" @@ -6706,6 +7370,16 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-parser@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/querystring-parser@npm:4.2.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/f3aae6561a973def4db9b3344c7860ae5659203cb912ac8aaba68e9ee67726534756fa51f93dfc23874a878e98a93744dd69946d86e045f87a08bbf42421c1f3 + languageName: node + linkType: hard + "@smithy/service-error-classification@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/service-error-classification@npm:3.0.2" @@ -6724,6 +7398,15 @@ __metadata: languageName: node linkType: hard +"@smithy/service-error-classification@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/service-error-classification@npm:4.2.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + checksum: 10/b77e79aa66eadba65acae98cea257162cc5b4733987dea458ffc5e4c997ea8ab64bf9fb3178ac15ee2159e616c3d87a71ecc0be91f5abff624e523602a364a9f + languageName: node + linkType: hard + "@smithy/shared-ini-file-loader@npm:^3.1.1, @smithy/shared-ini-file-loader@npm:^3.1.2": version: 3.1.2 resolution: "@smithy/shared-ini-file-loader@npm:3.1.2" @@ -6744,6 +7427,16 @@ __metadata: languageName: node linkType: hard +"@smithy/shared-ini-file-loader@npm:^4.4.1": + version: 4.4.1 + resolution: "@smithy/shared-ini-file-loader@npm:4.4.1" + dependencies: + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/a05814e89f66fef0b79e464d06e3c36c553fb8aebc68f08674776806f7f5c11e2e5b83d85f1a450fc5d447ca4c3471a967c90ebfead28f75c829df125967ed80 + languageName: node + linkType: hard + "@smithy/signature-v4@npm:^3.1.0": version: 3.1.1 resolution: "@smithy/signature-v4@npm:3.1.1" @@ -6775,6 +7468,22 @@ __metadata: languageName: node linkType: hard +"@smithy/signature-v4@npm:^5.3.6": + version: 5.3.6 + resolution: "@smithy/signature-v4@npm:5.3.6" + dependencies: + "@smithy/is-array-buffer": "npm:^4.2.0" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/types": "npm:^4.10.0" + "@smithy/util-hex-encoding": "npm:^4.2.0" + "@smithy/util-middleware": "npm:^4.2.6" + "@smithy/util-uri-escape": "npm:^4.2.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/6ee715d5000b2df51cbb8bf72d4fefbe159bacf65b8c3decc93bb11b97f1eb5daf5980d6083751dc4e39a6b8954a2709bb8f12d091f8ad87d1f4910537f494f4 + languageName: node + linkType: hard + "@smithy/smithy-client@npm:^3.1.2, @smithy/smithy-client@npm:^3.1.4": version: 3.1.4 resolution: "@smithy/smithy-client@npm:3.1.4" @@ -6789,6 +7498,21 @@ __metadata: languageName: node linkType: hard +"@smithy/smithy-client@npm:^4.10.0, @smithy/smithy-client@npm:^4.10.1": + version: 4.10.1 + resolution: "@smithy/smithy-client@npm:4.10.1" + dependencies: + "@smithy/core": "npm:^3.19.0" + "@smithy/middleware-endpoint": "npm:^4.4.0" + "@smithy/middleware-stack": "npm:^4.2.6" + "@smithy/protocol-http": "npm:^5.3.6" + "@smithy/types": "npm:^4.10.0" + "@smithy/util-stream": "npm:^4.5.7" + tslib: "npm:^2.6.2" + checksum: 10/efb5230ff1ea6d2661ca7bc89bbd5c6f4d710d7f9d62efa51f8f68476f71a5734b5877d666b2528b814b327c1deb07ee5ae877882e318d6d8fc1d9deb5e8357f + languageName: node + linkType: hard + "@smithy/smithy-client@npm:^4.6.4, @smithy/smithy-client@npm:^4.6.5": version: 4.6.5 resolution: "@smithy/smithy-client@npm:4.6.5" @@ -6813,6 +7537,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^4.10.0": + version: 4.10.0 + resolution: "@smithy/types@npm:4.10.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/04486a46b3c3bcb8fc024951041390261edb0aa7db4b8c467dfc8a7373d09d58c0106edabbaff3163af15271f113c236e7f835a405bcd8d7652ea49578e7eb35 + languageName: node + linkType: hard + "@smithy/types@npm:^4.5.0": version: 4.5.0 resolution: "@smithy/types@npm:4.5.0" @@ -6844,6 +7577,17 @@ __metadata: languageName: node linkType: hard +"@smithy/url-parser@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/url-parser@npm:4.2.6" + dependencies: + "@smithy/querystring-parser": "npm:^4.2.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/64080eae9fdad635178e1dbc611adcdad7a82ced4d2943445e90f0321c8a31ebcdec9fae8961d808bb59a7df100dc54acb43af158a9965e7a456acfb59bf0775 + languageName: node + linkType: hard + "@smithy/util-base64@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-base64@npm:3.0.0" @@ -6866,6 +7610,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-base64@npm:^4.3.0": + version: 4.3.0 + resolution: "@smithy/util-base64@npm:4.3.0" + dependencies: + "@smithy/util-buffer-from": "npm:^4.2.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/87065ca13e3745858e0bb0ab6374433b258c378ee2a5ef865b74f6a4208c56db7db2b9ee5f888e021de0107fae49e9957662c4c6847fe10529e2f6cc882426b4 + languageName: node + linkType: hard + "@smithy/util-body-length-browser@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-body-length-browser@npm:3.0.0" @@ -6884,6 +7639,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-body-length-browser@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-body-length-browser@npm:4.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/deeb689b52652651c11530a324e07725805533899215ad1f93c5e9a14931443e22b313491a3c2a6d7f61d6dd1e84f9154d0d32de62bf61e0bd8e6ab7bf5f81ed + languageName: node + linkType: hard + "@smithy/util-body-length-node@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-body-length-node@npm:3.0.0" @@ -6902,6 +7666,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-body-length-node@npm:^4.2.1": + version: 4.2.1 + resolution: "@smithy/util-body-length-node@npm:4.2.1" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/efb1333d35120124ec0c751b7b7d5657eb9ad6d0bf6171ff61fde2504639883d36e9562613c70eca623b726193b22601c8ff60e40a8156102d4c5b12fae222f8 + languageName: node + linkType: hard + "@smithy/util-buffer-from@npm:^2.2.0": version: 2.2.0 resolution: "@smithy/util-buffer-from@npm:2.2.0" @@ -6932,6 +7705,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-buffer-from@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-buffer-from@npm:4.2.0" + dependencies: + "@smithy/is-array-buffer": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/6a81e658554d7123fe089426a840b5e691aee4aa4f0d72b79af19dcf57ccb212dca518acb447714792d48c2dc99bda5e0e823dab05e450ee2393146706d476f9 + languageName: node + linkType: hard + "@smithy/util-config-provider@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-config-provider@npm:3.0.0" @@ -6950,6 +7733,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-config-provider@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-config-provider@npm:4.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/d65f36401c7a085660cf201a1b317d271e390258b619179fff88248c2db64fc35e6c62fe055f1e55be8935b06eb600379824dabf634fb26d528f54fe60c9d77b + languageName: node + linkType: hard + "@smithy/util-defaults-mode-browser@npm:^3.0.4": version: 3.0.6 resolution: "@smithy/util-defaults-mode-browser@npm:3.0.6" @@ -6976,6 +7768,18 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-browser@npm:^4.3.14": + version: 4.3.15 + resolution: "@smithy/util-defaults-mode-browser@npm:4.3.15" + dependencies: + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/smithy-client": "npm:^4.10.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/24f9b105f85a76a8df1da930892bf04e1d21dcbd1042d93b049d3c201be3e2c91c4d2b12244b740149d5b2353072578c96ef49c93e280e9948a582ab6fc2f0b3 + languageName: node + linkType: hard + "@smithy/util-defaults-mode-node@npm:^3.0.4": version: 3.0.6 resolution: "@smithy/util-defaults-mode-node@npm:3.0.6" @@ -7006,6 +7810,21 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-node@npm:^4.2.17": + version: 4.2.18 + resolution: "@smithy/util-defaults-mode-node@npm:4.2.18" + dependencies: + "@smithy/config-resolver": "npm:^4.4.4" + "@smithy/credential-provider-imds": "npm:^4.2.6" + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/property-provider": "npm:^4.2.6" + "@smithy/smithy-client": "npm:^4.10.1" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/a558ec6ceb0bff5e8e8a237b6baa03bba550a4a0d227c1458c59819c3bdd5a4671019e66aceca3071e30558fd50d45b03f3cdf3a76ad122a14f2561aafd7c457 + languageName: node + linkType: hard + "@smithy/util-endpoints@npm:^2.0.2": version: 2.0.3 resolution: "@smithy/util-endpoints@npm:2.0.3" @@ -7028,6 +7847,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-endpoints@npm:^3.2.6": + version: 3.2.6 + resolution: "@smithy/util-endpoints@npm:3.2.6" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/7ba0177c333a662d6c1019481ef262666dbcf103292c84acd2ef445bfe6a74576a83a09d1e9870692fd465ab14e2107d5f4b0c72ec55009255f26defa17229ea + languageName: node + linkType: hard + "@smithy/util-hex-encoding@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-hex-encoding@npm:3.0.0" @@ -7046,6 +7876,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-hex-encoding@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-hex-encoding@npm:4.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/478773d73690e39167b67481116c4fd47cecfc97c3a935d88db9271fb0718627bec1cbc143efbf0cd49d1ac417bde7e76aa74139ea07e365b51e66797f63a45d + languageName: node + linkType: hard + "@smithy/util-middleware@npm:^3.0.1, @smithy/util-middleware@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/util-middleware@npm:3.0.2" @@ -7066,6 +7905,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-middleware@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/util-middleware@npm:4.2.6" + dependencies: + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/ebb2f228b69301d9dbe63fb15fabe4408a9064ad834ecf4c1c77e5fca2171e11b21caa30e58553a1db0ec34b7d8cbbe7543ccc330f4b98210ef3ba5e47a1e024 + languageName: node + linkType: hard + "@smithy/util-retry@npm:^3.0.1, @smithy/util-retry@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/util-retry@npm:3.0.2" @@ -7088,6 +7937,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-retry@npm:^4.2.6": + version: 4.2.6 + resolution: "@smithy/util-retry@npm:4.2.6" + dependencies: + "@smithy/service-error-classification": "npm:^4.2.6" + "@smithy/types": "npm:^4.10.0" + tslib: "npm:^2.6.2" + checksum: 10/039d23283975352f8d6b668cd21043071f9f9299eba76fca8ec574e0bbbb2cdf44aeada7b929fb2a9f8a19d513d6e394b4c53e3654b757916de32e28834d042c + languageName: node + linkType: hard + "@smithy/util-stream@npm:^3.0.2, @smithy/util-stream@npm:^3.0.4": version: 3.0.4 resolution: "@smithy/util-stream@npm:3.0.4" @@ -7120,6 +7980,22 @@ __metadata: languageName: node linkType: hard +"@smithy/util-stream@npm:^4.5.7": + version: 4.5.7 + resolution: "@smithy/util-stream@npm:4.5.7" + dependencies: + "@smithy/fetch-http-handler": "npm:^5.3.7" + "@smithy/node-http-handler": "npm:^4.4.6" + "@smithy/types": "npm:^4.10.0" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-buffer-from": "npm:^4.2.0" + "@smithy/util-hex-encoding": "npm:^4.2.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/7142ea677aaba0c89a0ff45adee50f061d40a6a3dc5d1a7666c4b61e5ee15fbad9255e1fcffdc429a30a6428d543fcbe19baefb0be50563c8ea3134de04898b2 + languageName: node + linkType: hard + "@smithy/util-uri-escape@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-uri-escape@npm:3.0.0" @@ -7138,6 +8014,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-uri-escape@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-uri-escape@npm:4.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/a838a3afe557d7087d4500735c79d5da72e0cd5a08f95d1a1c450ba29d9cd85c950228eedbd9b2494156f4eb8658afb0a9a5bd2df3fc4f297faed886c396242b + languageName: node + linkType: hard + "@smithy/util-utf8@npm:^2.0.0": version: 2.3.0 resolution: "@smithy/util-utf8@npm:2.3.0" @@ -7168,6 +8053,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-utf8@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-utf8@npm:4.2.0" + dependencies: + "@smithy/util-buffer-from": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/d49f58fc6681255eecc3dee39c657b80ef8a4c5617e361bdaf6aaa22f02e378622376153cafc9f0655fb80162e88fc98bbf459f8dd5ba6d7c4b9a59e6eaa05f8 + languageName: node + linkType: hard + "@smithy/util-waiter@npm:^3.0.1": version: 3.1.0 resolution: "@smithy/util-waiter@npm:3.1.0" @@ -7199,6 +8094,15 @@ __metadata: languageName: node linkType: hard +"@smithy/uuid@npm:^1.1.0": + version: 1.1.0 + resolution: "@smithy/uuid@npm:1.1.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/fe77b1cebbbf2d541ee2f07eec6d4573af16e08dd3228758f59dcbe85a504112cefe81b971818cf39e2e3fa0ed1fcc61d392cddc50fca13d9dc9bd835e366db0 + languageName: node + linkType: hard + "@socket.io/component-emitter@npm:~3.1.0": version: 3.1.2 resolution: "@socket.io/component-emitter@npm:3.1.2" @@ -8789,6 +9693,7 @@ __metadata: resolution: "@uppy/aws-s3@workspace:packages/@uppy/aws-s3" dependencies: "@aws-sdk/client-s3": "npm:^3.362.0" + "@aws-sdk/client-sts": "npm:^3.362.0" "@aws-sdk/s3-request-presigner": "npm:^3.362.0" "@uppy/companion-client": "workspace:^" "@uppy/core": "workspace:^"