Ok, here we go! This explains how I got the users’ browser to do the work of uploading and downloading files to S3 for me.
TLDR; using my /api to create the bucket & request signed upload/download urls from AWS Lambda functions - I then executed uploads/downloads against those Urls from the React code.
I used some library to accept the images and convert them to DataURLs – data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA5MAAAP2CAMAAABNCDlSAAAD…
Then I used some old Lambda code I had to create signed upload and download urls (after creating a bucket with a widely permissive Cors configuration) – at some point I’ll have to back and tighten things up…
We’ll start with the Redwood /web from component to cell, thence to /api, and finally to the Lambda. If you’d like to start with the lambda and go backwards then you can read this from the bottom up.
Note: I’ve trimmed in Redwood for brevity, and stripped out some error checking, which may introduce slight bugs – but we’ve quite a ways to go.
Big Note: I stripped the DataURL prefix (i.e. data:image/png;base64,) storing just the base64 data in S3 - someone smarter than me could figure it out without that rigamarole
Side Note: I’ve only been doing React for a year on the side, so I’d welcome any tips on that stuff!
--------- Redwood /web component code ---------
Gather an image for uploading
// S3ImageUpload /web component
import { Button } from '@material-ui/core'
import TheS3UploadCell from 'src/cells/TheS3UploadCell'
const S3ImageUpload = ({ bucketName, keyFile, setUploadComplete }) => {
const [uploadRequested, setUploadRequested] = React.useState(false)
const [image, setImage] = React.useState<any>(undefined)
const setUploadCompleted = result => {
setTimeout(() => setUploadComplete(result))
}
return uploadRequested ? (
<TheS3UploadCell bucket={bucketName} s3key={keyFile} secs={duration} imgData={image.dataURL} exfiltrate={setUploadCompleted} />
) : (
// use your favorite library to receive images and expose the image.dataURL via setImage
// use your favorite library to receive images and expose the image.dataURL via setImage
<Button variant="contained" color="primary" onClick={() => setUploadRequested(true)}>
Upload
</Button>
)
}
export default ImageUpload;
Display a downloaded image and legend
// S3Image /web component
import { Box, CardMedia, Typography } from '@material-ui/core'
import useSharedClasses from 'src/hooks/shared-styles'
import TheS3DownloadCell from 'src/cells/TheS3DownloadCell'
const S3Image = ({ bucketName prefix, keyFile, duration, legend }) => {
const classes = useSharedClasses()
const [imageSrc, setImageSrc] = React.useState('')
return imageSrc ? (
<Box className={classes.s3Image}>
<Box className={classes.comfortable1}>
<Typography variant="inherit">{legend}</Typography>
</Box>
<Box className={classes.fourFifthsHeight}>
<CardMedia className={classes.fullHeight} classes={{ root: classes.containBackground }} image={`${prefix},${imageSrc}`} />
</Box>
</Box>
) : (
<TheS3DownloadCell bucket={bucketName} s3key={keyFile} secs={duration} exfiltrate={setImageSrc} />
)
}
export default S3Image
--------- Redwood /web cell code ---------
Request a signed Url & download the image data from S3
// TheS3DownloadCell /web cell
import ky from 'ky'
export const beforeQuery = (props) => {
const variables = { ...props }
return { variables, fetchPolicy: 'no-cache' }
}
export const QUERY = gql`
query TheS3DownloadCell($bucket: String!, $s3key: String!, $secs: String!) {
s3DownloadUrl(bucket: $bucket, key: $s3key, secs: $secs) {
doGetSignedUrlResult
}
}
`
export const Empty = () => null
export const Loading = () => null
export const Failure = ({ error }) => <div>Error: {error.message}</div>
export const Success = ({ s3DownloadUrl, variables }) => {
const { doGetSignedUrlResult: awsUrl } = s3DownloadUrl;
const { exfiltrate } = variables
ky.get(awsUrl)
.then(response => response.arrayBuffer())
.then(arrayBuffer => {
const enc = new TextDecoder("utf-8");
const imgData = enc.decode(arrayBuffer)
setTimeout(()=>exfiltrate(imgData))
})
return null
}
Request a signed Url & upload the image data from S3
// TheS3UploadCell /web cell
import ky from 'ky'
import ImageUpdateCell from 'src/cells/ImageUpdateCell'
import Progress from 'src/components/Progress'
export const beforeQuery = (props) => {
const { bucket, s3key, secs, imgData } = props
const [ prefix ] = imgData.split(',')
const [ ,,contentType] = prefix.split(/[^a-z]/)
const mimeType = `image/${contentType}` // enforce images only
const variables = { type: contentType, key: s3key, mimeType, ...props }
return { variables, fetchPolicy: 'no-cache' }
}
export const QUERY = gql`
query TheS3UploadCell($bucket: String!, $key: String!, $type: String!, $secs: String!) {
s3UploadUrl(bucket: $bucket, key: $key, type: $type, secs: $secs) {
doGetSignedUrlResult
}
}
`
export const Empty = () => null
export const Loading = () => <Progress />
export const Failure = ({ error }) => <div>Error: {error.message}</div>
export const Success = ({ s3UploadUrl, variables }) => {
const { doGetSignedUrlResult: awsUrl } = s3UploadUrl;
const { s3key, type, exfiltrate, imgData } = variables;
const [ prefix, ...base64Parts ] = imgData.split(',')
const base64 = base64Parts.join(',')
const headers = { ContentEncoding: 'base64', 'Content-Type': `image/${type}` }
ky.put(awsUrl, { body: base64, headers })
.then((result) => {
setTimeout(()=>exfiltrate(result))
})
return (
// this marks the image as uploaded & stores the prefix
<ImageUpdateCell id={s3key} name={s3key} prefix={prefix} />
)
}
--------- Redwood /api code ---------
Get the signed download Url
// s3DownloadUrl.ts /api service (calls lambda)
const fetch = require('node-fetch')
import { requireAuth } from 'src/lib/auth'
export const s3DownloadUrl = ({ bucket, key, secs }) => {
return fetch(`${process.env.storageDownloadPath}/${bucket}?key=${key}&secs=${secs}`, {
method: 'get', headers: {
Authorization: `Bearer ${process.env.bearerToken}`,
'Content-Type': 'application/json',
}
})
.then((res) => res.json())
.catch(err => logger.error(`s3download.js threw: ${err}`))
}
Get a signed upload Url
// s3UploadUrl.ts /api service (calls lambda)
const fetch = require('node-fetch')
export const s3UploadUrl = ({ bucket, key, type, secs }) => {
return fetch(`${process.env.storageUploadPath}/${bucket}?key=${key}&type=${type}&secs=${secs}`, {
method: 'get', headers: {
Authorization: `Bearer ${process.env.bearerToken}`,
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.catch(err => logger.error(`s3upload.js threw: ${err}`))
}
Create the bucket with a permissive Cors config
// createBucket /api utility (calls lambda)
export const createBucket = async ({ bucketName }) => {
return fetch(`${process.env.storageCreatePath}/${bucketName}`, {
method: 'get', headers: {
Authorization: `Bearer ${process.env.bearerToken}`,
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.catch((err) => logger.error(err))
};
For your interest, shared classes that work w/Material-UI
// shared-styles.ts /web utility
import { makeStyles, createStyles } from '@material-ui/core'
export default makeStyles((theme)=>createStyles({
comfortable1: {
padding: theme.spacing(1),
margin: theme.spacing(1),
},
containBackground: {
backgroundSize: 'contain !important',
},
fourFifthsHeight: {
height: '80%',
margin: theme.spacing(1),
width: '50%',
},
fullHeight: {
height: '100%',
},
s3Image: {
padding: theme.spacing(2),
height: '40vh',
width: '60vw',
},
}));