Rust言語で署名付きURLによりGCSにファイルをアップロードする

GCS（Google Cloud Storage）へファイルアップロードについて、仕組みの理解とRust言語でそれを実装する時にはまったことをメモする。

# 1. Google Cloud Storage 基本概念

## 1-1. バケット

##### 1-1-1. バケットとは
- ファイルをフォルダを格納するロジカル的なストレージ領域である。
ローカルPCだと、ドライブ名的なもの。（WindowsシステムのCドライブ、Dドライブ）

- アクセス方法：GCP　> Cloud Storage > ブラウザ > バケット名をクリック

##### 1-1-2. オブジェクト

不正確かもしれないが、バケット中のファイル名やフォルダ名のことを思えばよい。

##### 1-1-3. 権限

- 当たり前、基本はバケットは非公開
- プリンシパル・ロールで強力的に色々コントロールできる印象

## 1-2. サービスアカウント

- GCPの普通のユーザと違って、バックエンドAPIで利用するユーザのこと。
- プライベート鍵JSONファイルをダウンロードして、署名付きURL作成時に利用する

[Google Cloud Platformサービスアカウントを作成する](https://help.deepsecurity.trendmicro.com/aws/ja-jp/gcp-account-create.html)

## 1-3. 署名付きアップロードURL

- 下記情報が含まれる
  - アップロード先のバケットとオブジェクト名
  - http通信メソッド名（PUTかPOST）
  - http通信コンテンツタイプ（application/pdfなど）

- サービスアカウントのプライベート鍵で暗号化する
  - サービスアカウント名や暗号化アルゴリズムなどをURLのリクエストパラメータに追加

## 1-4. GCS側デフォルトで行う署名内容の確認（Validator）

- 指定サービスアカウントの公開鍵で認証する
- http通信メソッドなど一致しているかをチェック
- チェックOKだったらアップロードが行われる

## 1-5. 対象バケットをサービスアカウントのアクセスを許可する

次のどちらも行ける

##### 1-5-1. サービスアカウントで広い範囲を設定

- Cloudストレージ管理者権限を与えば、全てのGCSをアクセスできてしまう
  - バケットごとに細かく設定が不要になるメリットがある
  - 広すぎで、やられたら、影響範囲が広いデメリットが大きい
    - 管理者権限を与えるための権限が必要で、上長の承認を通らないいけないので、諦める

##### 1-5-2. バケット画面の権限・プリンシプルで該当サービスアカウントの権限を追加

[バケットの権限の設定](https://cloud.google.com/storage/docs/cloud-console?hl=ja#_bucketpermission)

# 2. アーキテクチャー
![architecture_with_GCP_services.max-1100x1100.max-900x900.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123446/e6f90e4d-4646-f9f5-f07e-b718bbb27051.png)

- フロントからバックエンドの署名付きURL作成apiを叩く
- 取得されたURLへファイルをアップロードする

# 3. Rustで署名付きURL作成APIとはまったこと

## 3-1.軽量クレート`tame_gcs`を利用

- 下記はactix-webフレームワークを利用してcontroller側の実装である
- GCS_SERVICE_ACCOUNT_JSON_FILE環境設定には実際のプライベート鍵JSONファイルのパスが設定される

```rust
extern crate diesel;
use dotenv::dotenv;
use std::env;
use serde::Serialize;
use serde::Deserialize;

use actix_web::HttpResponse;
use actix_web::web;
use tame_gcs::{signed_url, signing, types};
use http;


#[derive(Serialize)]
struct GoogleCloudStorageUrl<'a> {
    signed_file_upload_url: &'a str,
}

#[derive(Deserialize)]
pub struct Param {
    file_name: String,
    content_type: String,
}

fn signed_url(file_name: &str, content_type: &str) -> Result<url::Url, tame_gcs::Error> {
    dotenv().ok();

    let gcs_service_account_json_file = env::var("GCS_SERVICE_ACCOUNT_JSON_FILE").expect("GCS_SERVICE_ACCOUNT_JSON_FILE must be set");

    let sa = signing::ServiceAccount::load_json_file(gcs_service_account_json_file)?;

    let mut headers = http::HeaderMap::default();
    headers.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_str(content_type).unwrap());
    // let options = signed_url::SignedUrlOptional::default();
    let options = signed_url::SignedUrlOptional {
        method: http::Method::PUT,
        duration: std::time::Duration::from_secs(60 * 60),
        headers,
        region: "auto",
        query_params: Vec::new(),
    };
    let bucket = types::BucketName::try_from("hoge-upload-test")?;
    let object = types::ObjectName::try_from(file_name)?;
    let url_signer = signed_url::UrlSigner::with_ring();
    let signed_url = url_signer.generate(&sa, &(&bucket, &object), options)?;
    Ok(signed_url)
}

pub async fn file_upload_url(query: web::Query<Param>) -> HttpResponse {
    let file_name = query.file_name.as_str();
    let content_type = query.content_type.as_str();
    println!("file_name:{}", file_name);
    println!("content_type:{}", content_type);

    let url = signed_url(file_name, content_type).unwrap();
    println!("url:{}", url.as_str());

    HttpResponse::Ok().json(GoogleCloudStorageUrl {
        signed_file_upload_url: url.as_str(),
    })
}
```

Cargo.toml依頼の抜粋

```toml
[dependencies]
actix-web = "4.0.1"
actix-rt = "2.7.0"
tame-gcs = { version = "0.11.3", features = ["signing"] }
url = "2.2.2"
http = "0.2"
```

## 3-2. はまったこと

[この](https://qiita.com/FuJino/items/8380c5d474ede1f3d0f1)記事を参考してすぐに署名付きURLができましたが、それを使ってアップロードすると、理由不明で403エラーになった。

- chromeのコンソールにはなぜかCORSエラーと403が同時に表示される
  - CORSエラーがGCSかchromeのバグっぽい、それも目が惹かれってしまった
  - 具体的なレスポンスが何もないため、原因究明は難航
- Firefoxならエラーレスポンスがちゃんと見れた。なるほど、SignatureDoesNotMatch署名が一致しないことでした。

```
<?xml version='1.0' encoding='UTF-8'?><Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you provided. Check your Google secret key and signing method.</Message><StringToSign>GOOG4-RSA-SHA256
20220421T163054Z
20220421/auto/storage/goog4_request
c87e06a70044bf8632a035fa36def8e8804085a76bc15574bc95a5f1241bc0d4</StringToSign><CanonicalRequest>POST
/hoge-upload-test/test.pdf
X-Goog-Algorithm=GOOG4-RSA-SHA256&amp;X-Goog-Credential=cloud-storage-account-01%40hogehoge-stg.iam.gserviceaccount.com%2F20220421%2Fauto%2Fstorage%2Fgoog4_request&amp;X-Goog-Date=20220421T163054Z&amp;X-Goog-Expires=3600&amp;X-Goog-SignedHeaders=host
host:storage.googleapis.com

host
UNSIGNED-PAYLOAD</CanonicalRequest></Error>
```

- 原因判明

上記Qiita記事はアップロードためのURL作成ではないからだ。
  - デフォルトのSignedUrlOptionalを使ってGETための署名付きURLになる
  - GCS側でValidatorする時に、実際はPUTなので、当然通らない

```rust
let options = signed_url::SignedUrlOptional::default();
```

- 修正内容

ネット記事[Google Cloud Storage】署名付きURLによる画像のアップロード](https://tech.stmn.co.jp/entry/google_cloud_storage/image_upload_by_signed_url)をみると、http method名やcontent-typeなども設定する必要があるようで、デフォルトをやめてSignedUrlOptionalを生成したら、上手くいった。

# 4. フロント側の実装とCORSエラーの対処

フロント側がaxiosを使ってファイルアップロード通信を行なうところ、GCSと自サービスのドメインが違うためCORSエラーが発生する。
それを通るには、違うドメインでもいいよとフロント側通信設定とGCSサーバ設定が必要。

#### 4-1. axiosの設定

- Access-Control-Allow-Originをaxiosのdefaults設定に追加
  - 下記はputでアップロードする場合の設定
  - axios通信メソッドのその場のheader設定が上手くいかない
```js
axios.defaults.headers.put['Access-Control-Allow-Origin'] = '*';
```

- １つのファイルをアップロードする場合は、FormDataを使わない方良い

[Google Cloud Storageの署名付きURLに対してaxiosでPUTするときにFormDataを使おうとしてはならない](https://qiita.com/kai_kou/items/6cfa2fddbe3192a30f06)

- content-typeがアップロードファイルのタイプである
  - pdfファイルなら「application/pdf」

#### 4-2. GCSサーバ設定

gsutilsコマンドラインで行なう。（WEB画面で設定するかと思ったが、できなさそう。）

[gsutil ツール](https://cloud.google.com/storage/docs/gsutil?hl=ja)

- cors設定ファイル（cors_setting.json）を用意

```json          
[
    {
      "origin": ["*"],
      "responseHeader": ["*"],
      "method": ["*"],
      "maxAgeSeconds": 3600
    }
]
```

methodやresponseHeaderは必要によって後で変更でOK。

- gsutilsコマンドで対象バケットのCORSを設定

```sh
gsutil cors set cors_setting.json gs://hoge-upload-test
```

- CORS設定内容の確認

```sh
gsutil cors get gs://hoge-upload-test  
```

```javascript
const handleUpload = async () => {
    if (!file) return;

    axios.defaults.headers.put['Access-Control-Allow-Origin'] = '*';

    console.log('file:', file?.name);
    console.log('fileType:', file?.type);
    const idToken = await getIdToken();
    console.log('idToken:', idToken);
    const uploadUrl = await axios.get('/api/file_upload_url', {
      headers: {
        Authorization: `Bearer ${idToken}`,
      },
      params: {
        file_name: file.name,
        content_type: file.type,
      },
    }).then((response) => {
      return response.data.signed_file_upload_url;
    });
    console.log('uploadUrl:', uploadUrl);

    const res = await axios.put(uploadUrl, file, {
      headers: {
        'Content-Type': file.type,
      },
    });

    console.log('upload response:', res);
  };
```

#### 4-3. CORSエラーにはまったことと心得

##### はまったこと
CORSエラーが解決したのに、GCS４０３エラーが返された場合、chromeはなぜかCORSエラーログが出力されるので、正しく導かれない。

##### 心得
Chromeのdevtoolsでエラーレスポンスを確認できない場合、Firefoxを使ってエラーレスポンスを確認することが大事。

# 参考記事

- [署名付き URL を活用して Cloud Storage に画像ファイルを直接アップロードするアーキテクチャを設計する](https://cloud.google.com/blog/ja/products/gcp/uploading-images-directly-to-cloud-storage-by-using-signed-url)
- [rustでGoogleCloudStorageの署名付きURLを作成する](https://qiita.com/FuJino/items/8380c5d474ede1f3d0f1)
- [Google Cloud Storage】署名付きURLによる画像のアップロード
](https://tech.stmn.co.jp/entry/google_cloud_storage/image_upload_by_signed_url)
- [Google Cloud Storageの署名付きURLに対してaxiosでPUTするときにFormDataを使おうとしてはならない](https://qiita.com/kai_kou/items/6cfa2fddbe3192a30f06)