はじめに
現在、ベータ版リリース後の次の機能として、ユーザーがアプリでミーアに喋らせたい任意のテキストを再生時刻とともに入力したら、その時間で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生成関数
- AWS Configの読み込み: AWSの設定をロード。
- S3クライアントの初期化: S3クライアントを設定。
- プリサイン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
}
ユーザー定義フレーズの処理関数
- タイムゾーンの設定: JST(日本標準時)をロード。
- 現在の時間を取得: JSTでの現在の時間を取得し、フォーマット。
- データベースからスケジュールを取得: ユーザーのフレーズ情報を取得。
- プリサインURLの生成: 取得したvoice_pathでプリサインURLを生成。
- デバイスシャドウの更新: デバイスシャドウをプリサイン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)
}
動作確認
サーバー側ログ
- ユーザーの音声ファイルパスを取得。
- 2分間有効なプリサインURLを生成。
- プリサインURLをデバイスシャドウに更新。
- 処理が完了したことを記録。
clocky_api_local | 2024/07/23 21:44:00 Fetched voice path for user 1: users/1/user_phrase/user_phrase_20240721-140517.mp3
clocky_api_local | 2024/07/23 21:44:00 Generated presigned URL: https://mia-dev-api.s3.ap-northeast-1.amazonaws.com/users/1/user_phrase/user_phrase_20240721-140517.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=XXX%2F20240723%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240723T214400Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&x-id=GetObject&X-Amz-Signature=XXX
clocky_api_local | 2024/07/23 21:44:00 Updating device shadow for user 1 with presigned URL https://mia-dev-api.s3.ap-northeast-1.amazonaws.com/users/1/user_phrase/user_phrase_20240721-140517.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=XXX%2F20240723%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240723T214400Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&x-id=GetObject&X-Amz-Signature=XXX
clocky_api_local | 2024/07/23 21:44:00 Scheduled task completed for user: 1
デバイス側ログ
- デバイスがMQTTメッセージを受信し、プリサインURLを含むデバイスシャドウの更新を検知。
- これにより、ESP32は音声ファイルのダウンロードを開始する。
06:45:05.157 > MQTTPubSubClient::onMessage: $aws/things/XXX/shadow/update/delta {"version":391,"timestamp":XXX,"state":{"config":{"user_phrase_audio_url":"https://mia-dev-api.s3.ap-northeast-1.amazonaws.com/users/1/user_phrase/user_phrase_20240721-140517.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=XXX%2F20240723%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240723T214400Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&x-id=GetObject&X-Amz-Signature=XXX"}},"metadata":{"config":{"user_phrase_audio_url":{"timestamp":XXX}}}}
MQTTメッセージで送られてくるuser_phrase_audio_urlを直接クリックすると、有効期限内だとダウンロードでき、有効期限を過ぎるとaccess deniedになることを確認できた。