diff --git a/examples/aws-nodejs/README.md b/examples/aws-nodejs/README.md index 1fc8c7fb5..96d7d11a4 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 ae496293b..b1dbb88f5 100644 --- a/examples/aws-nodejs/index.js +++ b/examples/aws-nodejs/index.js @@ -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) - -// === === -// 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({}) - }, - ) -}) - -// === === - -// === === -// 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) - } -}) - -// === === - -// === === +// --------------------------------------------------------------------------- +// 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('/rewrite-test.html', (req, res) => { - res.setHeader('Content-Type', 'text/html') - // Inject bucket config as JS variables - const config = `` - 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 - const modifiedHtml = html.replace('', `${config}`) - res.send(modifiedHtml) - }) -}) app.get('/uppy.min.mjs', (req, res) => { res.setHeader('Content-Type', 'text/javascript') diff --git a/examples/aws-nodejs/public/index.html b/examples/aws-nodejs/public/index.html index 881a07567..d3a09d7b5 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/rewrite-test.html b/examples/aws-nodejs/public/rewrite-test.html deleted file mode 100644 index 350c19370..000000000 --- a/examples/aws-nodejs/public/rewrite-test.html +++ /dev/null @@ -1,126 +0,0 @@ - - - - - Uppy – AWS S3 Plugin Rewrite Test - - - - -

AWS S3 Plugin Rewrite Test

-

Testing the rewritten @uppy/aws-s3 plugin using S3mini.

- -
- Test 1: Using getCredentials (STS) -
-

Uses the /s3/sts endpoint to get temporary credentials. S3mini handles signing internally.

-
-
-
- -
- Test 2: Using signRequest (Server Signing) -
-

Uses the /s3/presign endpoint to sign each request on the server.

-
-
-
- - - - - - - diff --git a/examples/aws-nodejs/public/withCustomEndpoints.html b/examples/aws-nodejs/public/withCustomEndpoints.html deleted file mode 100644 index 2265a7bb7..000000000 --- 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 000000000..a1f4638ac --- /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 000000000..b758eca63 --- /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