@uppy/aws-s3: add endpoint option (#5173)

This commit is contained in:
Antoine du Hamel 2024-06-13 14:55:12 +02:00 committed by GitHub
parent 9421cea0fb
commit 087d8f07f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 642 additions and 494 deletions

View file

@ -102,6 +102,22 @@ const { someThingMyBackendReturns } = uppy.getFile(id).response;
about this in the
[plugin docs](https://uppy.io/docs/aws-s3-multipart/#when-should-i-use-it).
- Remove deprecated `prepareUploadParts` option.
- Companions options (`companionUrl`, `companionHeaders`, and
`companionCookieRules`) are renamed to more generic names (`endpoint`,
`headers`, and `cookieRules`).
Using Companion with the `@uppy/aws-s3` plugin only makes sense if you already
need Companion for remote providers (such as Google Drive). When using your
own backend, you can let Uppy do all the heavy lifting on the client which it
would normally do for Companion, so you dont have to implement that yourself.
As long as you return the JSON for the expected endpoints (see our
[server example](https://github.com/transloadit/uppy/blob/main/examples/aws-nodejs/index.js)),
you only need to set `endpoint`.
If you are using Companion, rename the options. If you have a lot of
client-side complexity (`createMultipartUpload`, `signPart`, etc), consider
letting Uppy do this for you.
### `@uppy/core`

View file

@ -166,7 +166,7 @@ import '@uppy/dashboard/dist/style.min.css';
const uppy = new Uppy()
.use(Dashboard, { inline: true, target: 'body' })
.use(AwsS3, {
companionUrl: 'https://companion.uppy.io',
endpoint: 'https://companion.uppy.io',
});
```
@ -215,22 +215,23 @@ uploaded.
:::
#### `companionUrl`
#### `endpoint`
URL to a [Companion](/docs/companion) instance (`string`, default: `null`).
URL to your backend or to [Companion](/docs/companion) (`string`, default:
`null`).
#### `companionHeaders`
#### `headers`
Custom headers that should be sent along to [Companion](/docs/companion) on
every request (`Object`, default: `{}`).
Custom headers that should be sent along to the [`endpoint`](#endpoint) on every
request (`Object`, default: `{}`).
#### `companionCookiesRule`
#### `cookiesRule`
This option correlates to the
[RequestCredentials value](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials)
(`string`, default: `'same-origin'`).
This tells the plugin whether to send cookies to [Companion](/docs/companion).
This tells the plugin whether to send cookies to the [`endpoint`](#endpoint).
#### `retryDelays`
@ -432,7 +433,7 @@ upload sources), you can pass a boolean:
```js
uppy.use(AwsS3, {
// This is an example using Companion:
companionUrl: 'http://companion.uppy.io',
endpoint: 'http://companion.uppy.io',
getTemporarySecurityCredentials: true,
shouldUseMultipart: (file) => file.size > 100 * 2 ** 20,
});

View file

@ -9,7 +9,7 @@ const uppy = new Uppy()
.use(Dashboard, { target: '#app', inline: true })
.use(AwsS3Multipart, {
limit: 2,
companionUrl: process.env.VITE_COMPANION_URL,
endpoint: process.env.VITE_COMPANION_URL,
shouldUseMultipart: true,
})

View file

@ -9,7 +9,7 @@ const uppy = new Uppy()
.use(Dashboard, { target: '#app', inline: true })
.use(AwsS3, {
limit: 2,
companionUrl: process.env.VITE_COMPANION_URL,
endpoint: process.env.VITE_COMPANION_URL,
shouldUseMultipart: false,
})

View file

@ -2,6 +2,7 @@
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')
@ -9,6 +10,7 @@ const express = require('express')
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 {
@ -21,19 +23,14 @@ const {
UploadPartCommand,
} = require('@aws-sdk/client-s3')
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
const {
STSClient,
GetFederationTokenCommand,
} = require('@aws-sdk/client-sts')
const { STSClient, GetFederationTokenCommand } = require('@aws-sdk/client-sts')
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: [
's3:PutObject',
],
Action: ['s3:PutObject'],
Resource: [
`arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}/*`,
`arn:aws:s3:::${process.env.COMPANION_AWS_BUCKET}`,
@ -54,10 +51,10 @@ let stsClient
const expiresIn = 900 // Define how long until a S3 signature expires.
function getS3Client () {
function getS3Client() {
s3Client ??= new S3Client({
region: process.env.COMPANION_AWS_REGION,
credentials : {
credentials: {
accessKeyId: process.env.COMPANION_AWS_KEY,
secretAccessKey: process.env.COMPANION_AWS_SECRET,
},
@ -65,10 +62,10 @@ function getS3Client () {
return s3Client
}
function getSTSClient () {
function getSTSClient() {
stsClient ??= new STSClient({
region: process.env.COMPANION_AWS_REGION,
credentials : {
credentials: {
accessKeyId: process.env.COMPANION_AWS_KEY,
secretAccessKey: process.env.COMPANION_AWS_SECRET,
},
@ -78,53 +75,61 @@ function getSTSClient () {
app.use(bodyParser.urlencoded({ extended: true }), bodyParser.json())
app.get('/', (req, res) => {
const htmlPath = path.join(__dirname, 'public', 'index.html')
res.sendFile(htmlPath)
})
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.
app.get('/drag', (req, res) => {
const htmlPath = path.join(__dirname, 'public', 'drag.html')
res.sendFile(htmlPath)
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.
app.get('/sts', (req, res, next) => {
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', '*')
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)
})
app.post('/sign-s3', (req, res, next) => {
const Key = `${crypto.randomUUID()}-${req.body.filename}`
const { contentType } = req.body
getSignedUrl(getS3Client(), new PutObjectCommand({
Bucket: process.env.COMPANION_AWS_BUCKET,
Key,
ContentType: contentType,
}), { expiresIn }).then((url) => {
res.setHeader('Access-Control-Allow-Origin', '*')
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)
// === <S3 Multipart> ===
// You can remove those endpoints if you only want to support the non-multipart uploads.
@ -133,7 +138,9 @@ 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' })
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' })
@ -154,7 +161,7 @@ app.post('/s3/multipart', (req, res, next) => {
next(err)
return
}
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
res.json({
key: data.Key,
uploadId: data.UploadId,
@ -162,7 +169,7 @@ app.post('/s3/multipart', (req, res, next) => {
})
})
function validatePartNumber (partNumber) {
function validatePartNumber(partNumber) {
// eslint-disable-next-line no-param-reassign
partNumber = Number(partNumber)
return Number.isInteger(partNumber) && partNumber >= 1 && partNumber <= 10_000
@ -172,20 +179,33 @@ app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => {
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.' })
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 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', '*')
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)
})
@ -196,39 +216,52 @@ app.get('/s3/multipart/:uploadId', (req, res, next) => {
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"' })
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 (startAt) {
client.send(new ListPartsCommand({
Bucket: process.env.COMPANION_AWS_BUCKET,
Key: key,
UploadId: uploadId,
PartNumberMarker: startAt,
}), (err, data) => {
if (err) {
next(err)
return
}
function listPartsPage(startAt) {
client.send(
new ListPartsCommand({
Bucket: process.env.COMPANION_AWS_BUCKET,
Key: key,
UploadId: uploadId,
PartNumberMarker: startAt,
}),
(err, data) => {
if (err) {
next(err)
return
}
parts.push(...data.Parts)
parts.push(...data.Parts)
if (data.IsTruncated) {
// Get the next page.
listPartsPage(data.NextPartNumberMarker)
} else {
res.json(parts)
}
})
if (data.IsTruncated) {
// Get the next page.
listPartsPage(data.NextPartNumberMarker)
} else {
res.json(parts)
}
},
)
}
listPartsPage(0)
})
function isValidPart (part) {
return part && typeof part === 'object' && Number(part.PartNumber) && typeof part.ETag === 'string'
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()
@ -237,29 +270,41 @@ app.post('/s3/multipart/:uploadId/complete', (req, res, next) => {
const { parts } = req.body
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 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 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,
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,
})
},
}), (err, data) => {
if (err) {
next(err)
return
}
res.setHeader('Access-Control-Allow-Origin', '*')
res.json({
location: data.Location,
})
})
)
})
app.delete('/s3/multipart/:uploadId', (req, res, next) => {
@ -268,24 +313,89 @@ app.delete('/s3/multipart/:uploadId', (req, res, next) => {
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 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({})
})
return client.send(
new AbortMultipartUploadCommand({
Bucket: process.env.COMPANION_AWS_BUCKET,
Key: key,
UploadId: uploadId,
}),
(err) => {
if (err) {
next(err)
return
}
res.json({})
},
)
})
// === </S3 MULTIPART> ===
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
// === <some plumbing to make the example work> ===
app.get('/', (req, res) => {
res.setHeader('Content-Type', 'text/html')
const htmlPath = path.join(__dirname, 'public', 'index.html')
res.sendFile(htmlPath)
})
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')
const bundlePath = path.join(
__dirname,
'../..',
'packages/uppy/dist',
'uppy.min.mjs',
)
if (existsSync(bundlePath)) {
res.sendFile(bundlePath)
} else {
console.warn(
'No local JS bundle found, using the CDN as a fallback. Run `corepack yarn build` to make this warning disappear.',
)
res.end(
'export * from "https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.mjs";\n',
)
}
})
app.get('/uppy.min.css', (req, res) => {
res.setHeader('Content-Type', 'text/css')
const bundlePath = path.join(
__dirname,
'../..',
'packages/uppy/dist',
'uppy.min.css',
)
if (existsSync(bundlePath)) {
res.sendFile(bundlePath)
} else {
console.warn(
'No local CSS bundle found, using the CDN as a fallback. Run `corepack yarn build` to make this warning disappear.',
)
res.end(
'@import "https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.css";\n',
)
}
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}.`)
console.log(`Visit http://localhost:${port}/ on your browser to try it.`)
})
// === </some plumbing to make the example work> ===

View file

@ -1,104 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Uppy</title>
<link
href="https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.css"
rel="stylesheet"
/>
</head>
<body>
<section class="example">
<div id="drag-drop-area"></div>
<div class="for-ProgressBar"></div>
<div class="uploaded-files">
<h5>Uploaded files:</h5>
<ol></ol>
</div>
<script type="module">
import {
Uppy,
DragDrop,
ProgressBar,
AwsS3,
} from 'https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.mjs'
// Function for displaying uploaded files
const onUploadSuccess = (elForUploadedFiles) => (file, response) => {
const url = response.uploadURL
const fileName = file.name
const li = document.createElement('li')
const a = document.createElement('a')
a.href = url
a.target = '_blank'
a.appendChild(document.createTextNode(fileName))
li.appendChild(a)
document.querySelector(elForUploadedFiles).appendChild(li)
}
var uppy = new Uppy({
autoProceed: true,
restrictions: {
maxNumberOfFiles: 10,
},
})
.use(DragDrop, {
inline: true,
target: '#drag-drop-area',
})
.use(ProgressBar, {
target: '.example .for-ProgressBar',
hideAfterFinish: true,
})
.use(AwsS3, {
getUploadParameters(file) {
// Send a request to our PHP signing endpoint.
return fetch('/sign-s3', {
method: 'post',
// Send and receive JSON.
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
})
.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,
// Provide content type header required by S3
headers: {
'Content-Type': file.type,
},
}
})
},
})
uppy.on('complete', (result) => {
console.log(
'Upload complete! Weve uploaded these files:',
result.successful,
)
})
uppy.on(
'upload-success',
onUploadSuccess('.example .uploaded-files ol'),
)
</script>
</section>
</body>
</html>

View file

@ -3,264 +3,68 @@
<head>
<meta charset="utf-8" />
<title>Uppy AWS upload example</title>
<link
href="https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.css"
rel="stylesheet"
/>
<link href="./uppy.min.css" rel="stylesheet" />
</head>
<body>
<h1>AWS upload example</h1>
<div id="uppy"></div>
<details open name="uppy">
<summary>Sign on the server</summary>
<div id="uppy-sign-on-server"></div>
</details>
<details name="uppy">
<summary>Sign on the client (if WebCrypto is available)</summary>
<div id="uppy-sign-on-client"></div>
</details>
<footer>
You seeing the simplified example, with a backend that mimicks a
Companion-like instance. See
<a href="./withCustomEndpoints.html">the custom endpoint example</a> if
you need to see how to use one or more custom function for handling
communication with the backend.
</footer>
<noscript>You need JavaScript to run this example.</noscript>
<script type="module">
import {
Uppy,
Dashboard,
AwsS3,
} from 'https://releases.transloadit.com/uppy/v4.0.0-beta.11/uppy.min.mjs'
/**
* This generator transforms a deep object into URL-encodable pairs
* to work with `URLSearchParams` on the client and `body-parser` on the server.
*/
function* serializeSubPart(key, value) {
if (typeof value !== 'object') {
yield [key, value]
return
}
if (Array.isArray(value)) {
for (const val of value) {
yield* serializeSubPart(`${key}[]`, val)
}
return
}
for (const [subkey, val] of Object.entries(value)) {
yield* serializeSubPart(key ? `${key}[${subkey}]` : subkey, val)
}
import { Uppy, Dashboard, AwsS3 } from './uppy.min.mjs'
function onUploadComplete(result) {
console.log(
'Upload complete! Weve uploaded these files:',
result.successful,
)
}
function serialize(data) {
// If you want to avoid preflight requests, use URL-encoded syntax:
return new URLSearchParams(serializeSubPart(null, data))
// If you don't care about additional preflight requests, you can also use:
// return JSON.stringify(data)
// You'd also have to add `Content-Type` header with value `application/json`.
function onUploadSuccess(file, data) {
console.log(
'Upload success! Weve uploaded this file:',
file.meta['name'],
)
}
{
const MiB = 0x10_00_00
const uppy = new Uppy()
.use(Dashboard, {
inline: true,
target: '#uppy',
target: '#uppy-sign-on-server',
})
.use(AwsS3, {
id: 'myAWSPlugin',
// Files that are more than 100MiB should be uploaded in multiple parts.
shouldUseMultipart: (file) => file.size > 100 * MiB,
/**
* This method tells Uppy how to retrieve a temporary token for signing on the client.
* Signing on the client is optional, you can also do the signing from the server.
*/
async getTemporarySecurityCredentials({ signal }) {
const response = await fetch('/sts', { signal })
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
return response.json()
},
// ========== Non-Multipart Uploads ==========
/**
* This method tells Uppy how to handle non-multipart uploads.
* If for some reason you want to only support multipart uploads,
* you don't need to implement it.
*/
async getUploadParameters(file, options) {
if (typeof crypto?.subtle === 'object') {
// If WebCrypto is available, let's do signing from the client.
return uppy
.getPlugin('myAWSPlugin')
.createSignedURL(file, options)
}
// Send a request to our Express.js signing endpoint.
const response = await fetch('/sign-s3', {
method: 'POST',
headers: {
accept: 'application/json',
},
body: serialize({
filename: file.name,
contentType: file.type,
}),
signal: options.signal,
})
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
// Parse the JSON response.
const data = await response.json()
// Return an object in the correct shape.
return {
method: data.method,
url: data.url,
fields: {}, // For presigned PUT uploads, this should be left empty.
// Provide content type header required by S3
headers: {
'Content-Type': file.type,
},
}
},
// ========== Multipart Uploads ==========
// The following methods are only useful for multipart uploads:
// If you are not interested in multipart uploads, you don't need to
// implement them (you'd also need to set `shouldUseMultipart: false` though).
async createMultipartUpload(file, signal) {
signal?.throwIfAborted()
const metadata = {}
Object.keys(file.meta || {}).forEach((key) => {
if (file.meta[key] != null) {
metadata[key] = file.meta[key].toString()
}
})
const response = await fetch('/s3/multipart', {
method: 'POST',
// Send and receive JSON.
headers: {
accept: 'application/json',
},
body: serialize({
filename: file.name,
type: file.type,
metadata,
}),
signal,
})
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
// Parse the JSON response.
const data = await response.json()
return data
},
async abortMultipartUpload(file, { key, uploadId, signal }) {
const filename = encodeURIComponent(key)
const uploadIdEnc = encodeURIComponent(uploadId)
const response = await fetch(
`/s3/multipart/${uploadIdEnc}?key=${filename}`,
{
method: 'DELETE',
signal,
},
)
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
},
async signPart(file, options) {
if (typeof crypto?.subtle === 'object') {
// If WebCrypto, let's do signing from the client.
return uppy
.getPlugin('myAWSPlugin')
.createSignedURL(file, options)
}
const { uploadId, key, partNumber, signal } = options
signal?.throwIfAborted()
if (uploadId == null || key == null || partNumber == null) {
throw new Error(
'Cannot sign without a key, an uploadId, and a partNumber',
)
}
const filename = encodeURIComponent(key)
const response = await fetch(
`/s3/multipart/${uploadId}/${partNumber}?key=${filename}`,
{ signal },
)
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
const data = await response.json()
return data
},
async listParts(file, { key, uploadId }, signal) {
signal?.throwIfAborted()
const filename = encodeURIComponent(key)
const response = await fetch(
`/s3/multipart/${uploadId}?key=${filename}`,
{ signal },
)
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
const data = await response.json()
return data
},
async completeMultipartUpload(
file,
{ key, uploadId, parts },
signal,
) {
signal?.throwIfAborted()
const filename = encodeURIComponent(key)
const uploadIdEnc = encodeURIComponent(uploadId)
const response = await fetch(
`s3/multipart/${uploadIdEnc}/complete?key=${filename}`,
{
method: 'POST',
headers: {
accept: 'application/json',
},
body: serialize({ parts }),
signal,
},
)
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
const data = await response.json()
return data
},
endpoint: '/',
})
uppy.on('complete', (result) => {
console.log(
'Upload complete! Weve uploaded these files:',
result.successful,
)
})
uppy.on('complete', onUploadComplete)
uppy.on('upload-success', onUploadSuccess)
}
{
const uppy = new Uppy()
.use(Dashboard, {
inline: true,
target: '#uppy-sign-on-client',
})
.use(AwsS3, {
id: 'myAWSPlugin',
endpoint: '/',
getTemporarySecurityCredentials: typeof crypto?.subtle === 'object',
})
uppy.on('upload-success', (file, data) => {
console.log(
'Upload success! Weve uploaded this file:',
file.meta['name'],
)
})
uppy.on('complete', onUploadComplete)
uppy.on('upload-success', onUploadSuccess)
}
</script>
</body>

View file

@ -0,0 +1,267 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Uppy AWS upload example</title>
<link href="./uppy.min.css" rel="stylesheet" />
</head>
<body>
<h1>AWS upload example</h1>
<div id="uppy"></div>
<footer>
You seeing the complex example, with a backend that does not mimick a
Companion-like instance. See
<a href="./index.html">the simplified example</a> if you don't need custom
functions for handling communication with the backend.
</footer>
<noscript>You need JavaScript to run this example.</noscript>
<script type="module">
import { Uppy, Dashboard, AwsS3 } from './uppy.min.mjs'
/**
* This generator transforms a deep object into URL-encodable pairs
* to work with `URLSearchParams` on the client and `body-parser` on the server.
*/
function* serializeSubPart(key, value) {
if (typeof value !== 'object') {
yield [key, value]
return
}
if (Array.isArray(value)) {
for (const val of value) {
yield* serializeSubPart(`${key}[]`, val)
}
return
}
for (const [subkey, val] of Object.entries(value)) {
yield* serializeSubPart(key ? `${key}[${subkey}]` : subkey, val)
}
}
function serialize(data) {
// If you want to avoid preflight requests, use URL-encoded syntax:
return new URLSearchParams(serializeSubPart(null, data))
// If you don't care about additional preflight requests, you can also use:
// return JSON.stringify(data)
// You'd also have to add `Content-Type` header with value `application/json`.
}
{
const MiB = 0x10_00_00
const uppy = new Uppy()
.use(Dashboard, {
inline: true,
target: '#uppy',
})
.use(AwsS3, {
id: 'myAWSPlugin',
// Files that are more than 100MiB should be uploaded in multiple parts.
shouldUseMultipart: (file) => file.size > 100 * MiB,
/**
* This method tells Uppy how to retrieve a temporary token for signing on the client.
* Signing on the client is optional, you can also do the signing from the server.
*/
async getTemporarySecurityCredentials({ signal }) {
const response = await fetch('/s3/sts', { signal })
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
return response.json()
},
// ========== Non-Multipart Uploads ==========
/**
* This method tells Uppy how to handle non-multipart uploads.
* If for some reason you want to only support multipart uploads,
* you don't need to implement it.
*/
async getUploadParameters(file, options) {
if (typeof crypto?.subtle === 'object') {
// If WebCrypto is available, let's do signing from the client.
return uppy
.getPlugin('myAWSPlugin')
.createSignedURL(file, options)
}
// Send a request to our Express.js signing endpoint.
const response = await fetch('/s3/sign', {
method: 'POST',
headers: {
accept: 'application/json',
},
body: serialize({
filename: file.name,
contentType: file.type,
}),
signal: options.signal,
})
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
// Parse the JSON response.
const data = await response.json()
// Return an object in the correct shape.
return {
method: data.method,
url: data.url,
fields: {}, // For presigned PUT uploads, this should be left empty.
// Provide content type header required by S3
headers: {
'Content-Type': file.type,
},
}
},
// ========== Multipart Uploads ==========
// The following methods are only useful for multipart uploads:
// If you are not interested in multipart uploads, you don't need to
// implement them (you'd also need to set `shouldUseMultipart: false` though).
async createMultipartUpload(file, signal) {
signal?.throwIfAborted()
const metadata = {}
Object.keys(file.meta || {}).forEach((key) => {
if (file.meta[key] != null) {
metadata[key] = file.meta[key].toString()
}
})
const response = await fetch('/s3/multipart', {
method: 'POST',
// Send and receive JSON.
headers: {
accept: 'application/json',
},
body: serialize({
filename: file.name,
type: file.type,
metadata,
}),
signal,
})
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
// Parse the JSON response.
const data = await response.json()
return data
},
async abortMultipartUpload(file, { key, uploadId, signal }) {
const filename = encodeURIComponent(key)
const uploadIdEnc = encodeURIComponent(uploadId)
const response = await fetch(
`/s3/multipart/${uploadIdEnc}?key=${filename}`,
{
method: 'DELETE',
signal,
},
)
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
},
async signPart(file, options) {
if (typeof crypto?.subtle === 'object') {
// If WebCrypto, let's do signing from the client.
return uppy
.getPlugin('myAWSPlugin')
.createSignedURL(file, options)
}
const { uploadId, key, partNumber, signal } = options
signal?.throwIfAborted()
if (uploadId == null || key == null || partNumber == null) {
throw new Error(
'Cannot sign without a key, an uploadId, and a partNumber',
)
}
const filename = encodeURIComponent(key)
const response = await fetch(
`/s3/multipart/${uploadId}/${partNumber}?key=${filename}`,
{ signal },
)
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
const data = await response.json()
return data
},
async listParts(file, { key, uploadId }, signal) {
signal?.throwIfAborted()
const filename = encodeURIComponent(key)
const response = await fetch(
`/s3/multipart/${uploadId}?key=${filename}`,
{ signal },
)
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
const data = await response.json()
return data
},
async completeMultipartUpload(
file,
{ key, uploadId, parts },
signal,
) {
signal?.throwIfAborted()
const filename = encodeURIComponent(key)
const uploadIdEnc = encodeURIComponent(uploadId)
const response = await fetch(
`s3/multipart/${uploadIdEnc}/complete?key=${filename}`,
{
method: 'POST',
headers: {
accept: 'application/json',
},
body: serialize({ parts }),
signal,
},
)
if (!response.ok)
throw new Error('Unsuccessful request', { cause: response })
const data = await response.json()
return data
},
})
uppy.on('complete', (result) => {
console.log(
'Upload complete! Weve uploaded these files:',
result.successful,
)
})
uppy.on('upload-success', (file, data) => {
console.log(
'Upload success! Weve uploaded this file:',
file.meta['name'],
)
})
}
</script>
</body>
</html>

View file

@ -29,7 +29,7 @@ describe('AwsS3Multipart', () => {
core.use(AwsS3Multipart)
const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
const err = 'Expected a `companionUrl` option'
const err = 'Expected a `endpoint` option'
const file = {}
const opts = {}
@ -330,8 +330,8 @@ describe('AwsS3Multipart', () => {
beforeEach(() => {
core = new Core<any, Body>()
core.use(AwsS3Multipart, {
companionUrl: '',
companionHeaders: {
endpoint: '',
headers: {
authorization: oldToken,
},
})
@ -340,7 +340,8 @@ describe('AwsS3Multipart', () => {
it('companionHeader is updated before uploading file', async () => {
awsS3Multipart.setOptions({
companionHeaders: {
endpoint: 'http://localhost',
headers: {
authorization: newToken,
},
})
@ -371,7 +372,8 @@ describe('AwsS3Multipart', () => {
Body
>
awsS3Multipart.setOptions({
companionHeaders: {
endpoint: 'http://localhost',
headers: {
authorization: newToken,
},
})

View file

@ -143,9 +143,15 @@ export interface AwsS3Part {
}
type AWSS3WithCompanion = {
companionUrl: string
companionHeaders?: Record<string, string>
companionCookiesRule?: string
endpoint: ConstructorParameters<
typeof RequestClient<any, any>
>[1]['companionUrl']
headers?: ConstructorParameters<
typeof RequestClient<any, any>
>[1]['companionHeaders']
cookiesRule?: ConstructorParameters<
typeof RequestClient<any, any>
>[1]['companionCookiesRule']
getTemporarySecurityCredentials?: true
}
type AWSS3WithoutCompanion = {
@ -253,11 +259,7 @@ type AWSS3MaybeMultipartWithoutCompanion<
shouldUseMultipart: (file: UppyFile<M, B>) => boolean
}
type RequestClientOptions = Partial<
ConstructorParameters<typeof RequestClient<any, any>>[1]
>
interface _AwsS3MultipartOptions extends PluginOpts, RequestClientOptions {
interface _AwsS3MultipartOptions extends PluginOpts {
allowedMetaFields?: string[] | boolean
limit?: number
retryDelays?: number[] | null
@ -285,7 +287,6 @@ const defaultOptions = {
// eslint-disable-next-line no-bitwise
(file.size! >> 10) >> 10 > 100) as any as true,
retryDelays: [0, 1000, 3000, 5000],
companionHeaders: {},
} satisfies Partial<AwsS3MultipartOptions<any, any>>
export default class AwsS3Multipart<
@ -303,6 +304,7 @@ export default class AwsS3Multipart<
| 'completeMultipartUpload'
> &
Required<Pick<AWSS3WithoutCompanion, 'uploadPartBytes'>> &
Partial<AWSS3WithCompanion> &
AWSS3MultipartWithoutCompanionMandatorySignPart<M, B> &
AWSS3NonMultipartWithoutCompanionMandatory<M, B>,
M,
@ -335,8 +337,7 @@ export default class AwsS3Multipart<
// We need the `as any` here because of the dynamic default options.
this.type = 'uploader'
this.id = this.opts.id || 'AwsS3Multipart'
// TODO: only initiate `RequestClient` is `companionUrl` is defined.
this.#client = new RequestClient(uppy, (opts as any) ?? {})
this.#setClient(opts)
const dynamicDefaultOptions = {
createMultipartUpload: this.createMultipartUpload,
@ -385,10 +386,59 @@ export default class AwsS3Multipart<
return this.#client
}
#setClient(opts?: Partial<AwsS3MultipartOptions<M, B>>) {
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<AwsS3MultipartOptions<M, B>>): void {
this.#companionCommunicationQueue.setOptions(newOptions)
super.setOptions(newOptions)
this.#setCompanionHeaders()
super.setOptions(newOptions as any)
this.#setClient(newOptions)
}
/**
@ -410,9 +460,9 @@ export default class AwsS3Multipart<
}
#assertHost(method: string): void {
if (!this.opts.companionUrl) {
if (!this.#client) {
throw new Error(
`Expected a \`companionUrl\` option containing a Companion address, or if you are not using Companion, a custom \`${method}\` implementation.`,
`Expected a \`endpoint\` option containing a URL, or if you are not using Companion, a custom \`${method}\` implementation.`,
)
}
}
@ -486,15 +536,18 @@ export default class AwsS3Multipart<
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 (this.opts.getTemporarySecurityCredentials === true) {
if (getTemporarySecurityCredentials === true) {
this.#assertHost('getTemporarySecurityCredentials')
this.#cachedTemporaryCredentials = this.#client
.get<AwsS3STSResponse>('s3/sts', options)
.then(assertServerError)
} else {
this.#cachedTemporaryCredentials =
this.opts.getTemporarySecurityCredentials(options)
(getTemporarySecurityCredentials as AWSS3WithoutCompanion['getTemporarySecurityCredentials'])!(
options,
)
}
this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials
setTimeout(
@ -572,7 +625,6 @@ export default class AwsS3Multipart<
abortMultipartUpload(
file: UppyFile<M, B>,
{ key, uploadId, signal }: UploadResultWithSignal,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
): Promise<void> {
this.#assertHost('abortMultipartUpload')
@ -920,7 +972,7 @@ export default class AwsS3Multipart<
}
#setCompanionHeaders = () => {
this.#client.setCompanionHeaders(this.opts.companionHeaders)
this.#client?.setCompanionHeaders(this.opts.headers!)
}
#setResumableUploadsCapability = (boolean: boolean) => {