Skip to content

Instantly share code, notes, and snippets.

@taylorhughes
Last active May 11, 2024 11:18
Show Gist options
  • Save taylorhughes/0d34a6c9a8015ae61b5113292c70c7e3 to your computer and use it in GitHub Desktop.
Save taylorhughes/0d34a6c9a8015ae61b5113292c70c7e3 to your computer and use it in GitHub Desktop.
Use a pre-signed S3 URL with a modern web uploader
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()
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);
}
},
};
}
@bradeckman
Copy link

bradeckman commented Feb 20, 2024

Great code. Where does "./base" refer to?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment