📘

【AWS】S3署名付き(presigned)URLを使用して、音声合成ファイルのアクセス制限を管理する方法

2024/07/24に公開

はじめに

様々な方言を話すおしゃべり猫型ロボット「ミーア」を開発中。

https://mia-cat.com/

現在、ベータ版リリース後の次の機能として、ユーザーがアプリでミーアに喋らせたい任意のテキストを再生時刻とともに入力したら、その時間でESP32から音声再生できるようにする機能を開発中。

ユーザーが作成したテキストは、アプリからサーバー側へAPIリクエストで送られたのちに、サーバー側で音声合成したのちにAWSの各ユーザーディレクトリ下のS3フォルダに格納する。

ただ、この音声フレーズはユーザーディレクトリ下なのでアクセス制限がデフォルトになっており、そのままだとESP32から音声ダウンロードできない。

なので、今回はpresigned URLを使用したいと思う。

プリサインURLとは?

プリサインURLは、クラウドストレージサービス(例:Amazon S3)内のオブジェクトへの一時的なアクセスを提供するURL。

このURLは特定の権限と有効期限で署名され、クラウドストレージの資格情報に直接アクセスすることなく、安全にファイルをダウンロードまたはアップロードすることができる。

AWS公式サイト

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html

プリサインURLの仕組み

プリサインURLは、署名やその他のパラメーターをURL自体に埋め込むことによって機能する。この署名はクラウドストレージの資格情報を使用して生成され、URLが指定された制約内でのみ使用できることを保証する。

クエリパラメーターの具体例

プリサインURLには、通常以下のようなクエリパラメーターが含まれる:

  • X-Amz-Algorithm:署名に使用されたアルゴリズム(例:AWS4-HMAC-SHA256)
  • X-Amz-Credential:署名に使用されたAWS資格情報
  • X-Amz-Date:リクエストが作成された日時
  • X-Amz-Expires:URLの有効期限(秒単位)
  • X-Amz-SignedHeaders:署名に含まれるヘッダー情報
  • X-Amz-Signature:リクエストの署名
https://your-bucket-name.s3.amazonaws.com/your-object-key?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=YOUR_CREDENTIALS&X-Amz-Date=20240724T123456Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=YOUR_SIGNATURE

プリサインURLの有効期限設定

プリサインURLでは、URLの有効期限を指定できる。

これは、アクセスが許可される期間を制限するための重要な機能。今回の実装の場合、プリサインURLをサーバーで生成した直後にデバイスシャドウを更新し、デバイスシャドウが更新されたらすぐにESP32がダウンロードを開始するので、有効期限は短め(例えば2分)で設定する。

それでは、概念を理解したところで実装に入りたいと思う。

サーバー側(Go)

ミーアでは、ユーザーが入力したテキストを音声合成し、AWS S3に保存する。その際、ESP32から音声ファイルにアクセスするためにプリサインURLを生成する。

プリサインURL生成関数

  1. AWS Configの読み込み: AWSの設定をロード。
  2. S3クライアントの初期化: S3クライアントを設定。
  3. プリサインURLの生成: 指定されたバケットとオブジェクトキーでプリサインURLを生成。

synthesize_speech.go

// presigned URL生成
func GeneratePresignedURL(ctx context.Context, bucketName, key string, expiry time.Duration) (string, error) {
	awsCfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		return "", fmt.Errorf("failed to load AWS config: %v", err)
	}
	s3Client := s3.NewFromConfig(awsCfg)

	presignClient := s3.NewPresignClient(s3Client)
	req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
		Bucket: aws.String(bucketName),
		Key:    aws.String(key),
	}, s3.WithPresignExpires(expiry))
	if err != nil {
		return "", fmt.Errorf("failed to generate presigned URL: %v", err)
	}
	return req.URL, nil
}

ユーザー定義フレーズの処理関数

  1. タイムゾーンの設定: JST(日本標準時)をロード。
  2. 現在の時間を取得: JSTでの現在の時間を取得し、フォーマット。
  3. データベースからスケジュールを取得: ユーザーのフレーズ情報を取得。
  4. プリサインURLの生成: 取得したvoice_pathでプリサインURLを生成。
  5. デバイスシャドウの更新: デバイスシャドウをプリサインURLで更新。

worker.go


// ユーザー定義フレーズの処理
func ProcessUserPhraseMessage(ctx context.Context, db *sqlx.DB, message Message, config *Config, shadowManager ShadowManager) {
	// タイムゾーンをロード
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		log.Fatalf("Failed to load the 'Asia/Tokyo' time zone: %v", err)
	}

	// 現在の時間をJSTで取得
	currentTimeJST := time.Now().In(jst)

	// クエリに使用する時刻と曜日をデバッグログに出力
	formattedTime := currentTimeJST.Format("15:04")
	weekday := currentTimeJST.Weekday().String()[:3]
	log.Printf("Query Time: %s", formattedTime)
	log.Printf("Query Weekday: %s", weekday)

	// スケジュールからユーザーフレーズ情報を取得
	var schedule struct {
		VoicePath string `db:"voice_path"`
	}
	err = db.Get(&schedule, "SELECT up.voice_path FROM phrase_schedules ps JOIN user_phrases up ON ps.phrase_id = up.id WHERE ps.user_id = ? AND TIME_FORMAT(ps.time, '%H:%i') = ? AND FIND_IN_SET(?, ps.days) > 0;", message.UserID, formattedTime, weekday)
	if err != nil {
		log.Printf("Failed to fetch user phrase schedule for user %d: %v", message.UserID, err)
		return
	}

	// デバッグログ: 取得したvoice_pathを出力
	log.Printf("Fetched voice path for user %d: %s", message.UserID, schedule.VoicePath)

	// プリサインドURLの生成
	s3Url, err := GeneratePresignedURL(ctx, config.AWSS3ApiBucket, schedule.VoicePath, 2*time.Minute)
	if err != nil {
		log.Printf("Failed to generate presigned URL for user %d: %v", message.UserID, err)
		return
	}
	log.Printf("Generated presigned URL: %s", s3Url)

	// デバイスシャドウを更新
	user, err := GetUser(db, message.UID)
	if err != nil {
		log.Printf("Failed to get user info for device shadow update: %v", err)
		return
	}
	log.Printf("Updating device shadow for user %d with presigned URL %s", message.UserID, s3Url)
	err = UpdateDeviceShadow(ctx, shadowManager, user.DeviceID.V, s3Url, "user_phrase")
	if err != nil {
		log.Printf("Failed to update device shadow for user %d: %v", message.UserID, err)
		return
	}
	log.Printf("Scheduled task completed for user: %d", message.UserID)
}

動作確認

サーバー側ログ
続きはこちらで記載しています。
https://kazulog.fun/dev/s3-presigned-url/

Discussion