From 84ba508cb447d4a149d77fe7b3b086faf6e49d14 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 22 Dec 2022 13:15:14 +0100 Subject: [PATCH 1/3] example: add multipart support to `aws-nodejs` --- examples/aws-nodejs/index.js | 163 ++++++++++++++++++++++++-- examples/aws-nodejs/package.json | 1 + examples/aws-nodejs/public/index.html | 58 ++++++--- 3 files changed, 194 insertions(+), 28 deletions(-) diff --git a/examples/aws-nodejs/index.js b/examples/aws-nodejs/index.js index f276492bf9..7997037c09 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,130 @@ 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 }) + }) +}) + +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..a9d9d33cd4 100644 --- a/examples/aws-nodejs/public/index.html +++ b/examples/aws-nodejs/public/index.html @@ -2,21 +2,26 @@ - Uppy + Uppy – AWS upload example -
+

AWS upload example

+

AWS S3 (non multipart)

+
+

AWS S3 multipart

+
From 0508cc5ab91e0b4c75d5fb84d0243fc0878dbf4c Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 22 Dec 2022 13:22:28 +0100 Subject: [PATCH 2/3] fix indentation --- examples/aws-nodejs/public/index.html | 80 +++++++++++++-------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/examples/aws-nodejs/public/index.html b/examples/aws-nodejs/public/index.html index a9d9d33cd4..a61b8a0cc0 100644 --- a/examples/aws-nodejs/public/index.html +++ b/examples/aws-nodejs/public/index.html @@ -15,41 +15,41 @@

AWS S3 multipart

import { Uppy, Dashboard, AwsS3Multipart, AwsS3 } from "https://releases.transloadit.com/uppy/v3.3.1/uppy.min.mjs" { const uppy = new Uppy() - .use(Dashboard, { - inline: true, - target: '#aws-non-multipart', - }) - .use(AwsS3, { - getUploadParameters (file) { - // Send a request to our Express.js 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, // For presigned PUT uploads, this should be left empty. - // Provide content type header required by S3 + .use(Dashboard, { + inline: true, + target: '#aws-non-multipart', + }) + .use(AwsS3, { + getUploadParameters (file) { + // Send a request to our Express.js signing endpoint. + return fetch('/sign-s3', { + method: 'post', + // Send and receive JSON. headers: { - 'Content-Type': file.type, + 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, // For presigned PUT uploads, this should be left empty. + // Provide content type header required by S3 + headers: { + 'Content-Type': file.type, + }, + } + }) + }, + }); uppy.on('complete', (result) => { console.log('Upload complete! We’ve uploaded these files:', result.successful) @@ -61,13 +61,13 @@

AWS S3 multipart

} { const uppy = new Uppy() - .use(Dashboard, { - inline: true, - target: '#aws-multipart', - }) - .use(AwsS3Multipart, { - companionUrl: window.origin, - }) + .use(Dashboard, { + inline: true, + target: '#aws-multipart', + }) + .use(AwsS3Multipart, { + companionUrl: window.origin, + }) uppy.on('complete', (result) => { console.log('Upload complete! We’ve uploaded these files:', result.successful) From 6155d597100dbba3a4f0c98b4c5c3bdf25e45325 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 22 Dec 2022 17:03:51 +0100 Subject: [PATCH 3/3] do not use `companionUrl` --- examples/aws-nodejs/index.js | 36 +++++++ examples/aws-nodejs/public/index.html | 149 ++++++++++++++++++++++---- 2 files changed, 167 insertions(+), 18 deletions(-) diff --git a/examples/aws-nodejs/index.js b/examples/aws-nodejs/index.js index 7997037c09..30cfc58ee6 100644 --- a/examples/aws-nodejs/index.js +++ b/examples/aws-nodejs/index.js @@ -131,6 +131,42 @@ app.get('/s3/multipart/:uploadId/:partNumber', (req, res, next) => { }) }) +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' } diff --git a/examples/aws-nodejs/public/index.html b/examples/aws-nodejs/public/index.html index a61b8a0cc0..77a051e796 100644 --- a/examples/aws-nodejs/public/index.html +++ b/examples/aws-nodejs/public/index.html @@ -20,10 +20,10 @@

AWS S3 multipart

target: '#aws-non-multipart', }) .use(AwsS3, { - getUploadParameters (file) { + async getUploadParameters (file) { // Send a request to our Express.js signing endpoint. - return fetch('/sign-s3', { - method: 'post', + const response = await fetch('/sign-s3', { + method: 'POST', // Send and receive JSON. headers: { accept: 'application/json', @@ -33,21 +33,23 @@

AWS S3 multipart

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, // For presigned PUT uploads, this should be left empty. - // Provide content type header required by S3 - headers: { - 'Content-Type': file.type, - }, - } }) + + 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: data.fields, // For presigned PUT uploads, this should be left empty. + // Provide content type header required by S3 + headers: { + 'Content-Type': file.type, + }, + } }, }); @@ -66,7 +68,118 @@

AWS S3 multipart

target: '#aws-multipart', }) .use(AwsS3Multipart, { - companionUrl: window.origin, + async createMultipartUpload(file, signal) { + if (signal?.aborted) { + const err = new DOMException('The operation was aborted', 'AbortError') + Object.defineProperty(err, 'cause', { __proto__: null, configurable: true, writable: true, value: signal.reason }) + throw err + } + + 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', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + 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, { uploadId, key, partNumber, signal }) { + if (signal?.aborted) { + const err = new DOMException('The operation was aborted', 'AbortError') + Object.defineProperty(err, 'cause', { __proto__: null, configurable: true, writable: true, value: signal.reason }) + throw err + } + + 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) { + if (signal?.aborted) { + const err = new DOMException('The operation was aborted', 'AbortError') + Object.defineProperty(err, 'cause', { __proto__: null, configurable: true, writable: true, value: signal.reason }) + throw err + } + + 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) { + if (signal?.aborted) { + const err = new DOMException('The operation was aborted', 'AbortError') + Object.defineProperty(err, 'cause', { __proto__: null, configurable: true, writable: true, value: signal.reason }) + throw err + } + + const filename = encodeURIComponent(key) + const uploadIdEnc = encodeURIComponent(uploadId) + const response = await fetch(`s3/multipart/${uploadIdEnc}/complete?key=${filename}`, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ parts }), + signal, + }) + + if (!response.ok) throw new Error('Unsuccessful request', { cause: response }) + + const data = await response.json() + + return data + } }) uppy.on('complete', (result) => {