为了账号安全,请及时绑定邮箱和手机立即绑定

简单且安全的从现代浏览器直接上传到S3的功能

嗨,我是 Taylor Hughes_。我叫泰勒·休斯,是一名软件开发工程师。我在Facebook、Google、Clubhouse以及其他多家初创公司中开发过应用并组建过团队。

在每个项目中,都会遇到让用户上传文件到S3桶的问题。找到合适的JavaScript组件和配置,并让它们协同工作完成这个任务感觉就像是在施展黑魔法。

AWS 的官方文档会让你设置一个额外的认证服务,例如 AWS Cognito,但其实你并不需要这么做。实际上,你不需要将整个 AWS JS SDK 引入到你的客户端代码中。

相反,我们可以使用预签名的URLs和现代的Web API,仅用大约一百行的代码就轻松地从浏览器上传到S3。

从用户的角度来看,浏览器上的解决方式如下:

  1. 用户点击“上传文件”并选择一个文件
  2. 根据这个文件的元数据,从服务器端API请求一个预签名的S3对象上传URL
  3. 根据这个预签名的S3 URL,浏览器使用XmlHttpRequest发送文件,并可以监视进度
  4. 一旦上传完成,浏览器将新文件键返回给API,从而触发API对上传文件的处理
  5. 大功告成!

如果你想直接看已完成的代码,这里有一个包含TypeScript前端和Python API处理器的代码示例:代码示例

本文会一步步来:

AWS 设置

首先,创建一个新的桶 yourproject-upload,不开启公共访问权限,并且没有设置访问策略。(我将上传的文件移到其他地方供公众使用。)设置在24小时后自动删除桶内所有内容。

第二,添加一个名为 web-upload-only 的新IAM用户。获取该新用户的访问密钥和密钥对中的秘密访问密钥,并将这些凭证按您通常的方式添加到后端web服务器中。(这些凭证应与您的主要的AWS访问凭证分开。)

第三:给 web-upload-only 授予新桶中 yourproject-upload/* 路径的 s3:PutObject 权限。(当我们返回预签名的 PutObject URL 时,限制可写入的密钥。)

为仅限 Web 上传的 IAM 用户设置的内联策略 (inline policy),允许该用户向 S3 存储桶上传对象。

最后,还要将 s3:GetObject 访问权限赋予你的常规 AWS 角色或用户。你的常规 AWS 角色或用户需要访问 yourproject-upload/* 桶。你还需要另一个权限更大的用户来将文件从上传桶移动到你的主桶或公共服务桶中。

网站:增加一个“创建上传”的API端点

有了新IAM用户的访问密钥和秘密访问密钥后,你现在可以创建预签名URL,让最终用户能将特定键写入S3。

新的端点的输入包括:

  • content_type — 文件的 MIME 类型,这将在浏览器中的 PUT 请求中设置为 Content-Type 头,并且必须包含在签名中。
  • filename — 文件名,我们可以通过获取文件扩展名来确保 S3 键拥有一个友好的扩展名。

根据这些输入,创建一个AWS S3客户端并生成一个签名的Put请求URL。如下所示的Python代码:

# 创建S3客户端实例
s3_client = boto3.client('s3')

# 生成签名的Put请求URL,过期时间为3600秒
url = s3_client.generate_presigned_url('put_object', Params={'Bucket': 'bucket_name', 'Key': 'object_key'}, ExpiresIn=3600)
    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,  
        )  

    @api_view("/upload/create")  
    def create_upload(request: Request) -> Response:  
        ext = request.validated_data["original_filename"].split(".")[-1].lower()  
        # 生成的S3路径中包含用户的ID和当前日期:  
        date = datetime.now().strftime("%Y%m%d")  
        key = f"uploads/{request.user.id}/{date}-{uuid.uuid4()}.{ext}"  
        # 生成预签名的上传URL:  
        presigned_upload_url = upload_s3_client().generate_presigned_url(  
            "put_object",  
            Params={  
                "Bucket": "yourproject-upload",  
                "Key": key,  
                "ContentType": request.validated_data["content_type"],  
            },  
            ExpiresIn=60 * 60,  
        )  
        # 将密钥和预签名的PutObject URL返回给客户端:  
        return success_response(  
            {"key": key, "presigned_upload_url": presigned_upload_url}  
        )
客户端:把这些都连接起来

现在在客户端,你需要添加一个文件输入控件 以获取一个 File 对象。一旦你有了一个 File 对象,你可以像平常一样通过新的 API 后端请求一个预签名的 URL,就像你平时请求其他 API 一样。

/**

* 获取预签名URL的函数,用于上传文件。
 */
function getPresignedUrl(file: File) {  
  // 发起API请求,参数分别为请求类型、API路径、请求体以及响应处理函数。
  return makeAPIRequest(  
    "POST",  
    "upload/create",  
    {  
      // 文件名: file.name, 文件类型: file.type,
      original_filename: file.name,  
      content_type: file.type,  
    },  
    // 响应处理函数,用于解析返回的包含键和预签名上传URL的对象。
    (response) => response as {  
      key: string;  
      presigned_upload_url: string;  
    },  
  );  
}

你可以使用 XMLHttpRequest 创建一个请求,将文件直接 PUT 到 S3 存储,然后。

    function uploadFile(  
      file: File,  
      presignedUploadUrl: string,  
      onProgress: (pct: number) => void,  
    ): Promise<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("上传失败: " + e.toString()));  
        });  
        xhr.upload.addEventListener("abort", (e) => {  
          reject(new Error("上传被取消: " + e.toString()));  
        });  
        xhr.addEventListener("load", (e) => {  
          如果(xhr.status === 200) {  
            resolve();  
          } else {  
            reject(new Error("上传失败 " + xhr.status));  
          }  
        });  
        xhr.open("PUT", presignedUploadUrl, true);  
        try {  
          xhr.send(file);  
        } catch (e) {  
          reject(new Error("上传失败: " + e.toString()));  
        }  
      });  
    }

如果你的项目中用了 React Hooks,你就可以这样把所有组件连接起来。

    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>,  
        ) => {  
          setUploadState("starting");  

          try {  
            // 从我们的后端API获取预签名的URL:  
            const { key, presigned_upload_url } = await getPresignedUrl(  
              file,  
            );  
            setUploadState("uploading");  
            // 使用XMLHttpRequest实际上传文件:  
            await uploadFile(file, presigned_upload_url, (pct) => {  
              setUploadProgress(pct);  
            });  
            setUploadState("finishing");  
            // 调用成功回调函数,传递文件的唯一标识符:  
            await onSuccess(key);  
            setUploadState("done");  
          } catch (e) {  
            setUploadState("error");  
            setUploadError(e);  
            // 设置上传状态为错误,并记录错误信息:  
          }  
        },  
      };  
    }
最后一步:使用刚上传的文件,最后一步来了

一旦上传完成,你可以将 S3 key 返回给 API,以便将其保存到某个地方或进行任何后续处理。

添加另一个API端点,该端点接受一个上传密钥(upload_key),这是仅用于上传的S3桶中的一个路径。然后您可以下载和验证该文件,或者将其复制到另一个桶,以便其他服务可以立即使用它。

(我倾向于在上传密钥中加入经过验证的用户ID,这样你就可以在这个端点内验证上传是否来自当前用户。)

这里是一个用Python将桶复制到公开桶的例子(如下所示):

    upload_key = request.validated_data["upload_key"]  
    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"用户文件复制失败,用户ID为:{request.user.id}")

就这样了,希望这能帮到你!再次给你链接到这段代码的gist页面。🥰

如果有的话,请告诉我你有什么意见或者想法@taylorhughes

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消