diff --git a/examples/aws-nodejs/index.js b/examples/aws-nodejs/index.js index f276492bf9..30cfc58ee6 100644 --- a/examples/aws-nodejs/index.js +++ b/examples/aws-nodejs/index.js @@ -1,15 +1,33 @@ +'use strict' + +const path = require('node:path') +const crypto = require('node:crypto') require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') }) const express = require('express') const app = express() -const path = require('node:path') -const port = process.env.PORT +const port = process.env.PORT ?? 8080 const bodyParser = require('body-parser') +let s3Client const aws = require('aws-sdk') +const expires = 800 // Define how long until a S3 signature expires. + +function getS3Client () { + s3Client ??= new aws.S3({ + signatureVersion: 'v4', + region: process.env.COMPANION_AWS_REGION, + credentials : new aws.Credentials( + process.env.COMPANION_AWS_KEY, + process.env.COMPANION_AWS_SECRET, + ), + }) + return s3Client +} + app.use(bodyParser.json()) app.get('/', (req, res) => { @@ -23,21 +41,20 @@ app.get('/drag', (req, res) => { }) app.post('/sign-s3', (req, res) => { - const s3 = new aws.S3() - const fileName = req.body.filename + const s3 = getS3Client() + const Key = `${crypto.randomUUID()}-${req.body.filename}` const { contentType } = req.body const s3Params = { - Bucket: process.env.S3_BUCKET, - Key: fileName, - Expires: 60, + Bucket: process.env.COMPANION_AWS_BUCKET, + Key, + Expires: expires, ContentType: contentType, - ACL: 'public-read', } - s3.getSignedUrl('putObject', s3Params, (err, data) => { + s3.getSignedUrl('putObject', s3Params, (err, data, next) => { if (err) { - console.log(err) - return res.end() + next(err) + return } const returnData = { url: data, @@ -48,6 +65,166 @@ app.post('/sign-s3', (req, res) => { }) }) +// === === +// 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, + } + + return client.createMultipartUpload(params, (err, data) => { + if (err) { + next(err) + return + } + res.json({ + key: data.Key, + uploadId: data.UploadId, + }) + }) +}) + +function validatePartNumber (partNumber) { + // eslint-disable-next-line no-param-reassign + partNumber = Number(partNumber) + return Number.isInteger(partNumber) && partNumber >= 1 && partNumber <= 10_000 +} +app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => { + const client = getS3Client() + 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 client.getSignedUrl('uploadPart', { + Bucket: process.env.COMPANION_AWS_BUCKET, + Key: key, + UploadId: uploadId, + PartNumber: partNumber, + Body: '', + Expires: expires, + }, (err, url) => { + if (err) { + next(err) + return + } + res.json({ url, expires }) + }) +}) + +app.get('/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"' }) + } + + const parts = [] + listPartsPage(0) + + function listPartsPage (startAt) { + client.listParts({ + Bucket: process.env.COMPANION_AWS_BUCKET, + Key: key, + UploadId: uploadId, + PartNumberMarker: startAt, + }, (err, data) => { + if (err) { + next(err) + return + } + + parts.push(...data.Parts) + + if (data.IsTruncated) { + // Get the next page. + listPartsPage(data.NextPartNumberMarker) + } else { + res.json(parts) + } + }) + } +}) + +function isValidPart (part) { + return part && typeof part === 'object' && typeof part.PartNumber === 'number' && 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.completeMultipartUpload({ + Bucket: process.env.COMPANION_AWS_BUCKET, + Key: key, + UploadId: uploadId, + MultipartUpload: { + Parts: parts, + }, + }, (err, data) => { + if (err) { + next(err) + return + } + 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.abortMultipartUpload({ + Bucket: process.env.COMPANION_AWS_BUCKET, + Key: key, + UploadId: uploadId, + }, (err) => { + if (err) { + next(err) + return + } + res.json({}) + }) +}) + +// === === + app.listen(port, () => { console.log(`Example app listening on port ${port}`) }) diff --git a/examples/aws-nodejs/package.json b/examples/aws-nodejs/package.json index ccb34d7abf..c0d651624b 100644 --- a/examples/aws-nodejs/package.json +++ b/examples/aws-nodejs/package.json @@ -4,6 +4,7 @@ "description": "Uppy for AWS S3 with a custom Node.js backend for signing URLs", "main": "index.js", "scripts": { + "dev": "node --watch index.js", "start": "node index.js" }, "private": true, diff --git a/examples/aws-nodejs/public/index.html b/examples/aws-nodejs/public/index.html index 0ac61527b9..77a051e796 100644 --- a/examples/aws-nodejs/public/index.html +++ b/examples/aws-nodejs/public/index.html @@ -2,36 +2,44 @@ - Uppy + Uppy – AWS upload example -
+

AWS upload example

+

AWS S3 (non multipart)

+
+

AWS S3 multipart

+