Last active
October 14, 2024 15:03
-
-
Save taylorhughes/0d34a6c9a8015ae61b5113292c70c7e3 to your computer and use it in GitHub Desktop.
Use a pre-signed S3 URL with a modern web uploader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import uuid | |
from datetime import datetime | |
import boto3 | |
from django.conf import settings | |
from mypy_boto3_s3 import S3Client | |
from rest_framework import serializers | |
from rest_framework.response import Response | |
from myproject.models import ProfilePhoto | |
from myproject.util.apiview import MyProjecetAugmentedRequest, myproject_api_view | |
from myproject.util.response import success_response, bad_request, server_error | |
AWS_BUCKET_UPLOAD = "myproject-upload" | |
AWS_REGION = "us-east-1" | |
def upload_s3_client() -> S3Client: | |
return boto3.client( | |
"s3", | |
aws_access_key_id=settings.UPLOAD_AWS_ACCESS_KEY_ID, | |
aws_secret_access_key=settings.UPLOAD_AWS_SECRET_ACCESS_KEY, | |
region_name=AWS_REGION, | |
) | |
class CreateUploadRequestSerializer(serializers.Serializer): | |
original_filename = serializers.CharField(required=True) | |
content_type = serializers.CharField(required=True) | |
class CreateUploadResponseSerializer(serializers.Serializer): | |
key = serializers.CharField(required=True) | |
presigned_upload_url = serializers.URLField(required=True) | |
@myproject_api_view( | |
CreateUploadRequestSerializer, CreateUploadResponseSerializer, ["POST"] | |
) | |
def create_upload(request: MyProjecetAugmentedRequest) -> Response: | |
ext = request.validated_data["original_filename"].split(".")[-1].lower() | |
# Include user ID and date in generated S3 path: | |
date = datetime.now().strftime("%Y%m%d") | |
key = f"uploads/{request.user.id}/{date}-{uuid.uuid4()}.{ext}" | |
# Generate the pre-signed URL: | |
presigned_upload_url = upload_s3_client().generate_presigned_url( | |
"put_object", | |
Params={ | |
"Bucket": AWS_BUCKET_UPLOAD, | |
"Key": key, | |
"ContentType": request.validated_data["content_type"], | |
}, | |
ExpiresIn=60 * 60, | |
) | |
# Return key & presigned PutObject URL to client: | |
return success_response( | |
CreateUploadResponseSerializer( | |
{"key": key, "presigned_upload_url": presigned_upload_url} | |
) | |
) | |
class SetProfilePhotoRequestSerializer(serializers.Serializer): | |
upload_key = serializers.CharField(required=True) | |
@myproject_api_view( | |
SetProfilePhotoRequestSerializer, EmptyResponseSerializer, ["POST"] | |
) | |
def set_profile_photo(request: Request) -> Response: | |
upload_key = request.validated_data["upload_key"] | |
if not upload_key.startswith(f"uploads/{request.user.id}/"): | |
return bad_request("Invalid upload key") | |
# TODO: Valdiate profile photo upload is actually a photo object. | |
ext = upload_key.split(".")[-1] | |
slug = slugify(request.validated_data["filename"]) | |
date = datetime.now().strftime(r"%Y%m%d_%H%M%S") | |
public_key = f"media/{request.user.id}/{date}-{slug}.{ext}" | |
try: | |
public_content_s3_client().copy( | |
CopySource={ | |
"Bucket": "yourproject-upload", | |
"Key": upload_key, | |
}, | |
Bucket="yourproject-public", | |
Key=public_key, | |
) | |
except Exception: | |
logging.exception(f"Failed to copy file for user={request.user.id}") | |
return server_error() | |
photo = ProfilePhoto.objects.create(user=request.user, file_key=public_key) | |
request.user.profile_photo = photo | |
request.user.save() | |
return success_response() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useContext, useState } from "react"; | |
import { makeAPIRequest } from "./base"; | |
type APICreateUpload = { | |
key: string; | |
presigned_upload_url: string; | |
}; | |
function getPresignedUrl(file: File) { | |
return makeAPIRequest( | |
"POST", | |
"upload/create", | |
{ | |
original_filename: file.name, | |
content_type: file.type, | |
}, | |
(content) => content as APICreateUpload, | |
); | |
} | |
function uploadFile( | |
file: File, | |
presignedUploadUrl: string, | |
onProgress: (pct: number) => void, | |
) { | |
return new Promise<void>((resolve, reject) => { | |
const xhr = new XMLHttpRequest(); | |
xhr.upload.addEventListener("progress", (e) => { | |
if (e.lengthComputable) { | |
const pct = e.loaded / e.total; | |
onProgress(pct * 100); | |
} | |
}); | |
xhr.upload.addEventListener("error", (e) => { | |
reject(new Error("Upload failed: " + e.toString())); | |
}); | |
xhr.upload.addEventListener("abort", (e) => { | |
reject(new Error("Upload aborted: " + e.toString())); | |
}); | |
xhr.addEventListener("load", (e) => { | |
if (xhr.status === 200) { | |
resolve(); | |
} else { | |
reject(new Error("Upload failed " + xhr.status)); | |
} | |
}); | |
xhr.open("PUT", presignedUploadUrl, true); | |
try { | |
xhr.send(file); | |
} catch (e) { | |
reject(new Error("Upload failed: " + e.toString())); | |
} | |
}); | |
} | |
export function useUpload() { | |
const [uploadState, setUploadState] = useState< | |
"idle" | "starting" | "uploading" | "finishing" | "done" | "error" | |
>("idle"); | |
const [uploadProgress, setUploadProgress] = useState(0); | |
const [uploadError, setUploadError] = useState<Error | null>(null); | |
return { | |
uploadState, | |
uploadProgress, | |
uploadError, | |
upload: async ( | |
file: File, | |
onSuccess: (uploadKey: string) => Promise<void>, | |
) => { | |
if (uploadState !== "idle") { | |
throw new Error("Already uploading"); | |
} | |
setUploadState("starting"); | |
setUploadProgress(0); | |
setUploadError(null); | |
try { | |
const { key, presigned_upload_url } = await getPresignedUrl(file); | |
setUploadState("uploading"); | |
await uploadFile(file, presigned_upload_url, (pct) => { | |
setUploadProgress(pct); | |
}); | |
setUploadState("finishing"); | |
await onSuccess(key); | |
setUploadState("done"); | |
} catch (e) { | |
setUploadState("error"); | |
setUploadError(e); | |
} | |
}, | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Great code. Where does "./base" refer to?