βΉοΈ I recently did some work on this for a project I am working on, and I don't know if it was just me, but it was not the most enjoyable experience trying to figure out the correct configuration for a frontend file upload to AWS s3 bucket.
I wanted to share my solution, hopefully, it can help others π.I also want to acknowledge that there are plenty of dependencies available to do all this work for you around file uploads, but considering the file upload API doesn't change very often, it might be a better option to manage this yourself.
AWS s3 bucket setup π
Setting up an account and logging in as a root user
I will leave it up to you to figure out (I can answer questions about that setup if a comment is left about it π).
Once logged in, add s3
to the search in the top left, beside the services
waffle menu, the top result "S3" will be what you click on. (It is useful to click on the star)
Now you want to create a bucket
I have left all the suggested default settings as they are, except for the "Block Public Access setting for this bucket" this setting I have deactivated and consented to.
Now scroll down to the bottom and click "Create Bucket".
That covers the bucket creation config, I am sure per your requirements, you can make the changes you need. For this example, default settings are good enough.
Bucket Permissions π
You should see your bucket in the "Buckets" table list now, click on it, and now click on the "Permissions" tab.
update the following sections to what you see below;
Bucket Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicListGet",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:List*", // could be "s3:ListBucket"
"s3:Get*" // could be "s3:GetObject"
],
"Resource": "arn:aws:s3:::view-my-property-au"
},
{
"Sid": "PutFiles",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::view-my-property-au/*"
}
]
}
In the above policy, we have two Statements
.
The first is for allowing access to view our images, either a list or individual images.
The second is allowing access to some
CRUD
actions see theprefix
for which actions we are allowing.The
Resource
each of the actions has an effect on is slightly different, this is a small distinction to show in this case, that you cannotPUT
files without a name or subfolder, you cannot delete all files in a bucket and you canGET
individual files.The
Bucket Policy Elements
each has a description that can better provide you with some context on theJSON
structure: Policy Language Overview & Here
Note: There are no "
Conditions
" for the Statement "Actions"
.
Conditional Actions can only be applied to one action, here we have an array of actions. In the case ofDelete
&Put
it is safe to assume that these actions would only be accessible after authentication, and this authentication would only grant access to the images the authed user owns, therefore it is safe to leave these actions without conditions on the s3 bucket. (That is my assumption, happy to hear otherwise, or of a better option).
Cross-origin resource sharing (CORS)
As stated in the cors section of the bucket; "The CORS configuration, written in JSON, defines a way for client web applications that are loaded in one domain to interact with resources in a different domain". This section feels pretty straightforward to me, but please leave a comment if you would like more info, or see here for some examples
[
{
"AllowedHeaders": [
"*" // could be defined if needed
],
"AllowedMethods": [
"HEAD",
"PUT",
"POST",
"GET"
],
"AllowedOrigins": [
"*" // should be defined once in production
],
"ExposeHeaders": [
"ETag",
"x-amz-meta-custom-header" // not always needed
]
}
]
AWS s3 client setup
import { S3Client } from '@aws-sdk/client-s3'
export const s3Client = new S3Client({
region: process.env.REGION,
credentials: {
accessKeyId: process.env.ACCESS_KEY,
secretAccessKey: process.env.SECRET_KEY,
},
})
Install dependency
yarn add @aws-sdk/client-s3
npm install @aws-sdk/client-s3
Add your keys to the
.env
file.(see below how to set up a user and get these keys)
ACCESS_KEY
SECRET_KEY
IAM user (Programmatic) setup π
The following steps are how you can get the above KEYs
Add
iam
to the search in the top left, beside theservices
waffle menu, the top result "IAM" will be what you click on.In the menu on the left click "users"
Click "Add users"
Add the User name and select "Access key - Programmatic access"
- More info reference: IAM users
Go to the next section, section 2
Here you can either create a group with the correct permissions or add the correct permissions direct to the user
If you have created a group with the correct permissions select it, Or
Search for
AmazonS3FullAccess
and select it to add to the user- If you have not set up a group you will only see a place to search for permissions. What you see will look similar to the image just below.
Then go to the next optional step.
At the end of this setup, you will be presented with the Access key ID
and the Secret access key
as listed above, copy them into your .env, .env.local...
Drag&Drop upload components π«³
UploadFile usage
<UploadFile<ListingDetailsData> // add type to make UploadFile more generic
{...{
formDataFields, // some state that is used for adding files to subfolder
currentFiles, // image urls
setCurrentFiles, // state setter for image urls
hasNewFiles, // optional boolean used for updating loader and or user feedback
setHasNewFiles, // state setter for hasNewFiles boolean
}}
/>
UploadFile component ποΈ
import React, { useState, Dispatch, SetStateAction } from 'react'
import dynamic from 'next/dynamic'
import { endPoint } from '../../utils/endPoint'
import { handleUploadFiles } from './fileUploadFunction'
import imageResize from './imageResize'
import useWindowSizeTracker from '../../hooks/useWindowSizeTracker' // a hook that keeps track of the window dimensions. Window size tracking could be done with TailwindCSS.
const FlexGridLayout = dynamic(() => import('../common/flexGridLayout')) // just a wrapper for flex which can be used as is or passed some TailwindCSS class styling.
const DragAndDrop = dynamic(() => import('./dragAndDrop'))
const BASE_URL = process.env.BACKEND_URL || endPoint
interface UploadFileProps<T> {
formDataFields: T
currentFiles: Record<string, string>[] // TODO:: should be better typed
setCurrentFiles?: Dispatch<SetStateAction<Record<string, string>[]>>
hasNewFiles?: boolean
setHasNewFiles?: Dispatch<SetStateAction<boolean>>
}
const UploadFile = <T,>({
formDataFields,
currentFiles,
setCurrentFiles,
// hasNewFiles,
// setHasNewFiles,
}: UploadFileProps<T>) => {
// ** OPTIONAL STATE you can pass to DragAndDrop if needed. **
// uncomment and pass to DragAndDrop if you want to use.
// const [error, setError] = useState<unknown>(null)
// const [uploadSize, setUploadSize] = useState<string>('0')
// const [uploadFiles, setUploadFiles] = useState<Record<string, string>>({})
// const [uploadProgress, setUploadProgress] = useState<string>('')
// const [uploadRunning, setUploadRunning] = useState<boolean>(false)
// TODO:: Maybe just do all of this with TailwindCSS
const { windowSize } = useWindowSizeTracker()
const handleDrop = (files: FileList) => {
for (let i = 0; i < files.length; i += 1) {
const file = files[i]
if (!file.name) {
return
}
const fileSize = file.size
// TODO:: add a function for file size check handling
const fileSizeCheck = Math.round(fileSize / 1024)
if (fileSizeCheck < 1024) {
handleUploadFiles<T>(
file,
`${BASE_URL}/s3-upload`,
formDataFields,
).then(response => {
setCurrentFiles(prevState => [...prevState, { imageUrl: response }])
})
} else {
const image = imageResize<T>(file, formDataFields)
setCurrentFiles(prevState => [...prevState, { imageUrl: image.url }])
}
}
}
return (
<>
<div className="w-full h-full flex text-left flex-col px-4">
<DragAndDrop
{...{
windowSize,
handleDrop,
}}
>
<FlexGridLayout
childContainerClass="w-12"
containerClass="w-full absolute h-28 top-52 left-1/2 transform -translate-x-1/2 flex flex-wrap items-center overflow-y-scroll mb-4 space-x-2 z-50"
>
{currentFiles?.length > 0 &&
currentFiles.map(({ imageUrl }: Record<string, string>) => {
return (
<img
src={imageUrl}
key={imageUrl}
alt="upload-preview-image"
className="w-full object-contain rounded"
/>
)
})}
</FlexGridLayout>
</DragAndDrop>
</div>
</>
)
}
export default UploadFile
FileList
is an object but has someiterator
properties likelength
. This means you will have to use the synchronous OGfor loop
πfileSizeCheck
here we want to know if each image is greater than1MB
or not if so we will resize it to be smaller.handleUploadFiles<T>
file
= image file`${BASE_URL}/s3-upload`
= path to Nextjs API handlerformDataFields
= object with some state in it for constructing an image path ins3 bucket
. (or whatever you need it for)imageResize<T>
uses thehandleUploadFiles<T>
function under the hood, once you have finished resizing your image the same args are passed to thehandleUploadFiles<T>
.handleUploadFiles<T>
expects a<File>
type not abase64
string, but you can change this if you prefer.
DragAndDrop component
import React, { useState, useEffect, useRef, ReactNode } from 'react'
import ActiveAnimation from '../../activeAnimation' // just a loader indicating to user image is being uploaded
import useEventListener from '../../../hooks/useEventListener'
import { WindowSizeTracker } from '../../../hooks/useWindowSizeTracker'
type DragAndDropProps = {
children: ReactNode // This could be made optional children?:
windowSize: WindowSizeTracker // { width: number, height: number }
handleDrop: (fileList: FileList) => void
uploadSize?: string
uploadRunning?: boolean
uploadProgress?: string
}
const DragAndDrop = ({
children,
handleDrop,
uploadProgress,
uploadRunning,
uploadSize,
windowSize,
}: DragAndDropProps) => {
const [dragging, setDragging] = useState<boolean>(false) // this state is used to provide user with feedback on dragging "safe" area.
const dropRef = useRef<HTMLDivElement>(null)
const selectRef = useRef<HTMLInputElement>(null)
const handleDrag = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation() // stops the event from being propagated through parent and child elements. So we can do what we want!
}
const handleDragIn = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
// conditional chaining can be used here if you prefer
// event.dataTransfer?.items?.length > 0
if (
event.dataTransfer !== undefined &&
event.dataTransfer.items &&
event.dataTransfer.items.length > 0
) {
setDragging(true) // TODO::consider a better name for this state
}
}
const handleDragOut = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
setDragging(false)
}
const handleDropItem = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
const target = event.target as HTMLInputElement
if (
event.dataTransfer === undefined &&
target.files &&
target.files.length > 0
) {
const fileList: FileList = target.files
handleDrop(fileList)
} else if (
event.dataTransfer !== undefined &&
event.dataTransfer.files &&
event.dataTransfer.files.length > 0
) {
handleDrop(event.dataTransfer.files)
}
}
useEventListener('dragenter', handleDragIn, dropRef)
useEventListener('dragleave', handleDragOut, dropRef)
useEventListener('dragover', handleDrag, dropRef)
useEventListener('drop', handleDropItem, dropRef)
useEventListener('change', handleDropItem, selectRef)
return (
<div className="top-0 w-full h-80 flex relative" ref={dropRef}>
<div className="h-full right-0 bottom-0 left-0 text-center absolute border-dashed border-2 border-gray-800 z-40 bg-gray-100">
{dragging && !uploadProgress && (
<div className="top-1/2 text-theme-grey text-4xl right-0 left-0 text-center absolute">
<div>
drop here
<span
role="img"
className="ml-2"
aria-label="Beaming Face with Smiling Eyes"
>
π
</span>
</div>
</div>
)}
{!dragging && !uploadProgress ? (
<div className="top-1/2 text-theme-grey text-4xl right-0 left-0 text-center absolute">
Drag and Drop
</div>
) : (
uploadProgress && (
<div className="top-1/2 text-theme-grey text-4xl right-0 left-0 text-center absolute">
{uploadProgress}
{uploadSize && (
<div>Error: image/s must be below 1Mb or 1028Kb in size</div>
)}
{uploadRunning && (
<ActiveAnimation active="" backgroundColour="" />
)}
</div>
)
)}
<label
htmlFor="file-upload"
className="flex flex-row items-center mt-16 mx-auto w-32 h-16 bg-contrast-colour hover:bg-gray-500 active:bg-gray-500 text-center text-white border border-1 border-gray-200 border-opacity-10 font-base rounded-lg cursor-pointer"
>
<span className="w-full text-center">
{windowSize.width >= 800 ? 'Upload' : 'Take photo'}
</span>
<input
multiple
type="file"
id="file-upload"
accept="image/*"
className="hidden"
ref={selectRef}
/>
</label>
</div>
{children}
</div>
)
}
export default DragAndDrop
dragging
state is used to indicate to the user they can drop the files in the "safe" area.target.files
Vsevent.dataTransfer.files
see this StackOverflow discussionhandleDrop
is aprop
passed down to handle uploading tos3 bucket
useEventListener
see Add custom context menu to your website with React & TypeScript for an example of this hookwindowSize.width
is used to show different text whenDragAndDrop
is being used on a mobile,all event listeners are listening on the respective
ref
input type file
takes care of uploading onmobile
device and the native mobile image select or image capture will take over in this caseWe have limited the
input
to anyimage/*
type, this is untested with Apple's image typeHEIC
Must be selected from the album, this includes capturing images with the camera
<input type="file" accept="image/*">
Must be captured from the camera, this is only allowing you to capture images with the camera no photo album access.
<input type="file" accept="image/*" capture="">
The
input
ishidden
with ahtmlFor
reference on the label so we can customize the style on the browser's default "Browse..." input. TheselectRef
handles the onChange functionality for us.children
in this case is just a thumbnail preview of all uploaded images.
Before dragging
After dragging
After the image is uploaded to the s3 bucket
The handleUploadFiles function
import { singlePostRequest } from '../../pages/api/requests' // just a wrapper for axios.post()
import { convertGenericTypeToListingDetails } from '../../forms/addListing/helpers/listingDetailsValueChecker' // some type checking on generic <Type> used in this component
import {
removeSpecialCharacters,
replaceWhiteSpace,
} from '../../utils/helpers/commonFunctions' // some regex helper functions to strip out unwanted string items
type FileResponse = {
url: string
fields: Record<string, string> // TODO:: better type this
}
type FileRequest = {
fileType: string
fileName: string
}
export const handleUploadFiles = async <T>(
file: File,
apiUrl: string,
formDataFields?: T, // type is checked before being used here
) => {
const fileName = encodeURIComponent(file.name)
const fileType = encodeURIComponent(file.type)
const details = convertGenericTypeToListingDetails(formDataFields)
const fileTitleFormatted = encodeURIComponent(
replaceWhiteSpace(removeSpecialCharacters(details.fileTitle, ' '), '-'),
)
// get presigned put url to send file to s3 bucket
// TODO:: probably should move this presigned POST to its own little function and add better typing for it.
const { data } = await singlePostRequest<FileRequest, FileResponse>(
`${apiUrl}?file=${fileName}&fileType=${fileType}&listingId=${details.fileId}&listingTitle=${fileTitleFormatted}`,
{
fileType,
fileName,
},
)
const { url, fields } = data
const formData = new FormData()
// NOTE:: the order of which these fields are appended to the FormData object are important
// the file: File must be the list attribute in the formData object.
Object.entries({ ...fields, file }).forEach(([key, value]) => {
formData.append(key, value)
})
// NOTE:: this post request is successful if and only if it returns a 204 status.
const uploadResponse = await singlePostRequest<FormData, unknown>(
`${url}`,
formData,
)
if (uploadResponse) {
// return image url path used in your s3 bucket of image just uploaded this is used for thumbnail preview.
return `${url}${details.fileId}-${fileTitleFormatted}/${fileName}`
} else {
// TODO:: add a toast or some feedback here and logging here
console.error('Upload failed.')
}
}
Get Presigned URL for uploading files from the frontend to the s3 bucket
data
url
= The values used in the URL used to get the presigned URL must match, see below for how these fields are used in the upload config for presigned URL.fields
= These fields must be sent in the payload body of thepost request
that is using the presigned URL, along with your file.- I recommend
console.log('presigned fields ->', fields)
the fields to get a better understanding of what this object consists of.
- I recommend
The file must be the last item on the payload body object, or in this case the
formData
objectObject.entries({ ...fields, file })
Upload config for presigned URL, API handler
import { NextApiRequest, NextApiResponse } from 'next'
import { createPresignedPost } from '@aws-sdk/s3-presigned-post'
import { s3Client } from '../../../lib/awsS3Client'
const uploadHandler = async (
req: NextApiRequest,
res: NextApiResponse,
): Promise<void> => {
const {
query: { file, fileType, fileId, fileTitle },
method,
} = req
switch (method) {
case 'POST':
try {
const post = await createPresignedPost(s3Client, {
Bucket: process.env.S3_BUCKET_NAME,
// Key: must match POST url for file upload
Key: `${fileId}-${fileTitle}/${file}`,
Fields: {
ContentType: fileType as string,
},
Expires: 600, // seconds
Conditions: [
{ bucket: process.env.S3_BUCKET_NAME },
['starts-with', '$ContentType', 'image/'],
],
})
res.status(200).json(post)
} catch (error: any) {
console.error('π ~ file: index.ts ~ line 45 ~ error', error)
// throw error for errorWrapper to handle?
// send to some error logging service?
res.json(error)
// throw error
}
default:
res.status(405).end(`Method ${method} Not Allowed`)
}
}
export default uploadHandler
You will need to install
@aws-sdk/s3-presigned-post
npm install @aws-sdk/s3-presigned-post
yarn add @aws-sdk/s3-presigned-post
createPresignedPost
handles all the complexity of getting the presigned URL for file upload, you can see it can be configured with options passed in.Key:
= This matches the folder and file path used in thes3 bucket
which goes after the presigned URL.Conditions:
= Checking the bucket is correct and matches theBucket:
and also check the file type is allowed.This API handler only allows
POST
methodYou will need to add the following key values to your
.env
fileS3_BUCKET_NAME
BACKEND_URL
this most likely will be/api/
or something very similar.
There is a lot to go through in this blog, yell out if something doesn't quite make sense or you see some smelly code! At this point you can now drag and drop multiple image files and have them upload to a sub folder directory in your AWS s3 bucket.
Any questions leave me a comment π or dΓ©jame un comentario en espaΓ±ol π
(I am working on my espanol)