update aws-nodejs examples (#6187)

- removed old examples , entirely remove withCustomEndpoints.html as
every s3 operations are now handled by s3mini so no need of it.
- add new examples and update README.md
This commit is contained in:
Prakash 2026-03-06 00:15:05 +05:30 committed by prakash
parent 779c19c9d7
commit 50de6d1066
No known key found for this signature in database
7 changed files with 285 additions and 874 deletions

View file

@ -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 youre trying to setup the user `MY-UPPY-USER` to put the uploaded
files to the bucket `MY-UPPY-BUCKET`, heres 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 dont 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

View file

@ -1,462 +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 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 app = express()
const port = process.env.PORT ?? 8080
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}`,
],
},
],
}
app.use(bodyParser.json())
/**
* @type {S3Client}
*/
let s3Client
// --- S3 signing routes ---
app.use(require('./routes/sts'))
app.use(require('./routes/presign'))
/**
* @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)
// === <S3 Multipart> ===
// 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()
})
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
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({})
},
)
})
// === </S3 MULTIPART> ===
// === <S3 Pre-signed URL Endpoint for Plugin Rewrite v3> ===
// This endpoint returns pre-signed URLs for S3 operations
// Used by the rewritten @uppy/aws-s3 plugin with signRequest option
app.post('/s3/presign', async (req, res, next) => {
try {
const { method, key, uploadId, partNumber, contentType } = req.body
const client = getS3Client()
if (!method || !key) {
return res.status(400).json({ error: 'method and key are required' })
}
const bucket = process.env.COMPANION_AWS_BUCKET
let command
// Determine which command to use based on method and params
if (method === 'PUT' && uploadId && partNumber) {
// UploadPart
command = new UploadPartCommand({
Bucket: bucket,
Key: key,
UploadId: uploadId,
PartNumber: parseInt(partNumber, 10),
})
} else if (method === 'PUT') {
// 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,
// Note: parts are sent in the request body, not in the presigned URL
})
} 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: 900 })
res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
res.json({ url })
} catch (err) {
next(err)
}
})
// === </S3 Pre-signed URL Endpoint for Plugin Rewrite v3> ===
// === <some plumbing to make the example work> ===
// ---------------------------------------------------------------------------
// Static file serving
// ---------------------------------------------------------------------------
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('/rewrite-test.html', (req, res) => {
res.setHeader('Content-Type', 'text/html')
// Inject bucket config as JS variables
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 = `<script>
window.UPPY_S3_BUCKET = "${process.env.COMPANION_AWS_BUCKET}";
window.UPPY_S3_REGION = "${process.env.COMPANION_AWS_REGION}";
</script>`
const htmlPath = path.join(__dirname, 'public', 'rewrite-test.html')
require('node:fs').readFile(htmlPath, 'utf8', (err, html) => {
if (err) return res.status(500).send('Error loading page')
// Inject config before </head>
const modifiedHtml = html.replace('</head>', `${config}</head>`)
res.send(modifiedHtml)
res.setHeader('Content-Type', 'text/html')
res.send(html.replace('</head>', `${config}</head>`))
})
})
app.get('/index.html', (req, res) => {
res.setHeader('Location', '/').sendStatus(308).end()
})
app.get('/uppy.min.mjs', (req, res) => {
res.setHeader('Content-Type', 'text/javascript')

View file

@ -2,69 +2,117 @@
<html>
<head>
<meta charset="utf-8" />
<title>Uppy AWS upload example</title>
<title>Uppy AWS S3 upload example</title>
<link href="./uppy.min.css" rel="stylesheet" />
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
h1 { color: #1269cf; }
details { margin: 20px 0; }
summary { cursor: pointer; font-weight: bold; padding: 10px; background: #f5f5f5; border-radius: 4px; }
.description { margin: 10px 0; color: #555; }
</style>
</head>
<body>
<h1>AWS upload example</h1>
<h1>AWS S3 upload example</h1>
<p>Demonstrates the <code>@uppy/aws-s3</code> plugin with two signing modes.</p>
<details open name="uppy">
<summary>Sign on the server</summary>
<div id="uppy-sign-on-server"></div>
<summary>Sign on the client (STS credentials)</summary>
<p class="description">
Uses <code>getCredentials</code> to fetch temporary STS credentials from
<code>/s3/sts</code>. The browser signs all S3 requests locally using SigV4.
</p>
<div id="uppy-sts"></div>
</details>
<details name="uppy">
<summary>Sign on the client (if WebCrypto is available)</summary>
<div id="uppy-sign-on-client"></div>
<summary>Sign on the server (presigned URLs)</summary>
<p class="description">
Uses <code>signRequest</code> to call <code>/s3/presign</code> for each S3
operation. The server generates presigned URLs; the browser uses them directly.
</p>
<div id="uppy-sign"></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 './uppy.min.mjs'
import { Uppy, Dashboard, AwsS3, GoldenRetriever } from './uppy.min.mjs'
function onUploadComplete(result) {
console.log(
'Upload complete! Weve uploaded these files:',
result.successful,
)
console.log('Upload complete!', result.successful)
}
function onUploadSuccess(file, data) {
console.log(
'Upload success! Weve uploaded this file:',
file.meta['name'],
)
console.log('Upload success:', file.meta.name, data)
}
function onUploadError(file, error) {
console.error('Upload error:', file?.meta?.name, error)
}
// Mode 1: Client-side signing with STS credentials
{
const uppy = new Uppy()
const uppy = new Uppy({ debug: true })
.use(Dashboard, {
inline: true,
target: '#uppy-sign-on-server',
target: '#uppy-sts',
height: 400,
})
.use(AwsS3, {
id: 'myAWSPlugin',
endpoint: '/',
bucket: window.UPPY_S3_BUCKET,
region: window.UPPY_S3_REGION,
getCredentials: async ({ signal }) => {
const response = await fetch('/s3/sts', { signal })
if (!response.ok) throw new Error('Failed to get credentials')
const data = await response.json()
return {
credentials: {
accessKeyId: data.credentials.AccessKeyId,
secretAccessKey: data.credentials.SecretAccessKey,
sessionToken: data.credentials.SessionToken,
expiration: data.credentials.Expiration,
},
region: data.region,
}
},
})
.use(GoldenRetriever)
uppy.on('complete', onUploadComplete)
uppy.on('upload-success', onUploadSuccess)
uppy.on('upload-error', onUploadError)
}
// Mode 2: Server-side signing with presigned URLs
{
const uppy = new Uppy()
const uppy = new Uppy({ debug: true })
.use(Dashboard, {
inline: true,
target: '#uppy-sign-on-client',
target: '#uppy-sign',
height: 400,
})
.use(AwsS3, {
id: 'myAWSPlugin',
endpoint: '/',
getTemporarySecurityCredentials: typeof crypto?.subtle === 'object',
bucket: window.UPPY_S3_BUCKET,
region: window.UPPY_S3_REGION,
signRequest: async (request) => {
const response = await fetch('/s3/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: request.method,
key: request.key,
uploadId: request.uploadId,
partNumber: request.partNumber,
contentType: request.contentType,
}),
})
if (!response.ok) throw new Error('Failed to get presigned URL')
return response.json()
},
})
.use(GoldenRetriever)
uppy.on('complete', onUploadComplete)
uppy.on('upload-success', onUploadSuccess)
uppy.on('upload-error', onUploadError)
}
</script>
</body>

View file

@ -1,126 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Uppy AWS S3 Plugin Rewrite Test</title>
<link href="./uppy.min.css" rel="stylesheet" />
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; }
h1 { color: #1269cf; }
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px; }
.test-section h2 { margin-top: 0; color: #333; }
footer { margin-top: 40px; color: #666; font-size: 14px; }
details { margin: 20px 0; }
summary { cursor: pointer; font-weight: bold; padding: 10px; background: #f5f5f5; border-radius: 4px; }
</style>
</head>
<body>
<h1>AWS S3 Plugin Rewrite Test</h1>
<p>Testing the rewritten <code>@uppy/aws-s3</code> plugin using S3mini.</p>
<details open name="test">
<summary>Test 1: Using getCredentials (STS)</summary>
<div class="test-section">
<p>Uses the <code>/s3/sts</code> endpoint to get temporary credentials. S3mini handles signing internally.</p>
<div id="uppy-sts"></div>
</div>
</details>
<details name="test">
<summary>Test 2: Using signRequest (Server Signing)</summary>
<div class="test-section">
<p>Uses the <code>/s3/presign</code> endpoint to sign each request on the server.</p>
<div id="uppy-sign"></div>
</div>
</details>
<footer>
<a href="./index.html">← Back to original example</a>
</footer>
<noscript>You need JavaScript to run this example.</noscript>
<script type="module">
import { Uppy, Dashboard, AwsS3 } from './uppy.min.mjs'
// Common event handlers
function onUploadComplete(result) {
console.log('Upload complete!', result.successful)
}
function onUploadSuccess(file, data) {
console.log('Upload success:', file.meta.name, data)
}
function onUploadError(file, error) {
console.error('Upload error:', file?.meta?.name, error)
}
// Test 1: Using getCredentials (STS)
{
const uppy = new Uppy({ debug: true })
.use(Dashboard, {
inline: true,
target: '#uppy-sts',
height: 400,
})
.use(AwsS3, {
id: 'AwsS3-STS',
bucket: window.UPPY_S3_BUCKET, // Set by server
region: window.UPPY_S3_REGION || 'us-east-1',
// Get temporary credentials from /s3/sts
getCredentials: async ({ signal }) => {
const response = await fetch('/s3/sts', { signal })
if (!response.ok) throw new Error('Failed to get credentials')
const data = await response.json()
return {
credentials: {
accessKeyId: data.credentials.AccessKeyId,
secretAccessKey: data.credentials.SecretAccessKey,
sessionToken: data.credentials.SessionToken,
},
bucket: data.bucket,
region: data.region,
}
},
})
uppy.on('complete', onUploadComplete)
uppy.on('upload-success', onUploadSuccess)
uppy.on('upload-error', onUploadError)
}
// Test 2: Using signRequest (Server Signing)
{
const uppy = new Uppy({ debug: true })
.use(Dashboard, {
inline: true,
target: '#uppy-sign',
height: 400,
})
.use(AwsS3, {
id: 'AwsS3-Sign',
bucket: window.UPPY_S3_BUCKET,
region: window.UPPY_S3_REGION || 'us-east-1',
// Sign requests via /s3/presign endpoint (returns presigned URLs)
signRequest: async (request) => {
const response = await fetch('/s3/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method: request.method,
key: request.key,
uploadId: request.uploadId,
partNumber: request.partNumber,
contentType: request.contentType,
}),
})
if (!response.ok) throw new Error('Failed to get presigned URL')
return response.json()
},
})
uppy.on('complete', onUploadComplete)
uppy.on('upload-success', onUploadSuccess)
uppy.on('upload-error', onUploadError)
}
</script>
</body>
</html>

View file

@ -1,267 +0,0 @@
<!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

@ -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

View file

@ -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