「なろう系」ラノベのタイトルみたいですみません。
概要
- Angular / Firebase を使って Web アプリを開発
- ファイルを Firebase Storage で管理し、画面のボタンをポチッと押したらファイルがダウンロードできる機能を実装
- "getBlob" と "getDownloadURL" の両関数で同じ事象が発生
- でも実際にやってみたら CORS エラーが発生して失敗
- どうしたら解消できるのか調べてみると、解決方法に辿り着くまでに割と沼ってしまったので手順を公開することにした
- 本記事は Anguler で実装したケースの内容だが、React/Next 等の他技術でも解決方法は同じです(なぜなら、原因は Firebase 側の設定の問題であるため)
- 単純に生成AIに質問しても、結局この答えにはすぐ行きつかなかったのと、何度試しても生成された回答が「ソレジャナイ」感。そちらは、"おまけ編" として最後に補足
前提
- ざっくりアーキテクチャ
- Angular v18
- AngularFire も利用
- フロントのホストには Firebase Hosting を利用(App Hosting ではない)
- Firebase Storage をもちろん利用
- 今回は関係ないけど Firestora も利用
発生事象
処理内容
実際のソースコード(getBlob 関数を使う方法)
import { ref, getBlob } from '@angular/fire/storage';
/** ストレージファイルをダウンロードする */
public async downloadFile(storageFilePath: string): Promise<void> {
// ファイルのバイナリデータをダウンロード
const fileRef = ref(this.storage, storageFilePath);
const blobFile = await getBlob(fileRef); // バイナリデータを直接ダウンロードする方法
// ダウンロードしたファイルを処理する
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blobFile);
link.download = fileRef.name; // ファイル名
link.click();
}
ちなみに、getDownloadURL 関数を使う方法はこちら(動作は同じ)
import { ref, getDownloadURL } from '@angular/fire/storage';
// getDownloadURL 関数は通常の Firebase のライブラリにも含まれているはず
/** ストレージファイルをダウンロードする */
public async downloadFile(storageFilePath: string): Promise<void> {
// ファイルのダウンロード URL を取得
const fileRef = ref(this.storage, storageFilePath);
const downloadURL = await getDownloadURL(fileRef);
// fetch APIを使用してファイルを取得
const response = await fetch(downloadURL);
const blobFile = await response.blob();
// ダウンロードしたファイルを処理する
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blobFile);
link.download = fileRef.name; // ファイル名
link.click();
}
👆 たぶん、React 系使ってる人は、こちらのケースが多いと思われ。
発生した事象
Web ブラウザのコンソールに表示された CORS エラー
Access to fetch at 'https://firebasestorage.googleapis.com/v0/b/ombo-apps.appspot.com/o/{パス}/{画像ファイル名}.png?alt=media&token=xxxxx' from origin 'http://localhost:4200' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
原因
具体的には、ローカル環境 http://localhost:4200
から Firebase https://firebasestorage.googleapis.com
にリクエストしたけど、CORS ポリシーに抵触するのでブロックされた(尚、ローカル環境ではなく、実際にデプロイした Web サイトからでも同じ事象が発生)。
つまり、コードの処理は正しく動いてるが、Firebase のルールの問題で失敗している。
対策・解消方法
これを解消するには、Firebase 側に、特定の条件で許可をしてもらうように設定する必要がある。
対応手順
まず、現状の Firebase の Web コンソールでは、CORS ポリシーに対する詳細設定ができるページはなさそう。これを設定するには、gsutil(GS Util)というツールをマシンにインストールし、CLI のコマンドによって CORS の許可設定をしていく必要がある。
ざっくりとした流れは以下の通り👇
① gsutil を導入(既にあれば省略)
② CORS 設定ファイルを用意
③ gsutil を使って Firebase の対象プロジェクトに適用
gsutil のインストール
既にインストールされているかは、ターミナルのバージョン確認コマンドでわかる
# バージョン確認
$ gsutil version
# インストール済みなら結果が出る
gsutil version: 5.31
インストールされていなければ、以下の手順で導入できる
- google-cloud-sdk のダウンロード
- 必要に応じてマシンの Python バージョンを合わせる
- SDK を利用して gsutil インストール
こちらのインストール手順の詳細はこちら👇
https://cloud.google.com/storage/docs/gsutil_install?hl=ja
CORS 設定ファイルを用意
CORS のポリシーを変更する設定ファイルをどこかに用意する。どこでも良いが、おそらくGit管理しているプロジェクトの配下に用意した方が良さそう
設定ファイルを作成する
touch cors.json
中身は以下の通りとする
[
{
"origin": [
"https://example.com",
"https://example.web.app",
"http://localhost:4200"
],
"responseHeader": ["Content-Type"],
"method": ["GET"],
"maxAgeSeconds": 3600
}
]
※ origin 以外の設定は(当時の)公式サンプルのデフォルトですが、独自に細かい考慮があれば適宜修正してください
注意点 ⚠️
- origin は、1つのみであっても配列形式で指定する(末尾要素にカンマを入れないように注意)
- Firebase にデプロイしても使えるように、デプロイ先の URL(
.web.app
のやつ)も指定する - カスタムアドレス(独自ドメイン)を当てている場合は、それも指定する
- ローカルホストの指定は脆弱性を生むため、本来は使用を避けるべき
- 確認が終わったら削除した方がよい
- ローカルでの動作確認は、本番の Firebase ではなくエミュレータを利用すべき
適用コマンドを実行
利用コマンド
実際に設定適用に使用するコマンドは以下
gsutil cors set {対象ファイル} gs://{プロジェクトID}.appspot.com
また、実際に適用されている設定内容を確認するには以下
gsutil cors get gs://{プロジェクトID}.appspot.com
プロジェクトIDや gs://~
の部分は、Firebase の Web コンソールを確認したらわかる。
例として、cors.json
というファイルを用意して、Firebase プロジェクトIDが example
だった場合は、以下のコマンドとなる
# サンプル設定コマンド
$ gsutil cors set cors.json gs://example.appspot.com
# 設定確認コマンド
$ gsutil cors get gs://example.appspot.com
固定スクリプトにしておいた方がいい
上記のコマンドを随時実行する方法でも良いが、package.json の scripts などに記載しておき、npm コマンドで実行できるようにしておいた方が間違いがなくて良いと思う。
なので、package.json に以下を追記
{
:
"scripts": {
:
"cors:get": "gsutil cors get gs://{プロジェクトID}.appspot.com",
"cors:update": "gsutil cors set cors.json gs://{プロジェクトID}.appspot.com"
},
}
実行結果
まず、何も設定がない状態で、確認コマンドを実行すると以下のようになる
# npm コマンドで確認 (gsutil cors get でも同じ)
$ npm run cors:get
# 実行結果
gs://{プロジェクトID}.appspot.com/ has no CORS configuration.
設定コマンドを実行すると以下のようになる
# npm コマンドで確認 (gsutil cors set でも同じ)
$ npm run cors:update
# 実行結果
Setting CORS on gs://{プロジェクトID}.appspot.com/...
もう一度、確認コマンドを実行して、適用を確認する
# npm コマンドで確認 (gsutil cors get でも同じ)
$ npm run cors:get
# 実行結果 (例)
[{"maxAgeSeconds": 3600, "method": ["GET"], "origin": ["https://{独自ドメイン}", "https://{プロジェクトID}.web.app", "http://localhost:4200"], "responseHeader": ["Content-Type"]}]
以上で対策は完了。
実際に適用後にファイルダウンロードを試してみたら、問題なくできるようになっていた。
まとめ
Firebase を利用する場合、CORSポリシーに関するエラーが出たら、gsutil ツールで設定を追加すると回避できる
おまけ:生成AIに聞いてみたけど、すぐ解決できんかった件
ちなみに、生成 AI に質問してみたら以下のような回答フローだった👇
(Claude 3.5 Sonnet や o3-mini-high などプレミアムモデル使用しても同様の結果だった)
🥸ワイ(getBlob のソースコードで質問開始)
🥸ワイ「こんなエラーが発生してアカンのやけど、直して?(エラーログと関連コードを貼り付け)」
🤖AI「getDownloadURL を使いなよ。ソースコードほれ。」
🥸ワイ「関数が違うだけで、結局、同じエラー発生するんやけど」
🤖AI「じゃあ、fetch やめて、window.open 使ってブラウザの標準的なダウンロード機能を使う方法にしよか」
🥸ワイ「・・・(いや、ダメだろ)」
んで、実際に提案してきたのがこちら👇
/** ストレージファイルをダウンロードする */
public async downloadFile(storageFilePath: string): Promise<void> {
const fileRef = ref(this.storage, storageFilePath);
const downloadURL = await getDownloadURL(fileRef);
// window.openを使用して直接ダウンロードを開始
window.open(downloadURL, '_blank');
}
これの何がダメか?
- 実際の動作は、ブラウザの 別タブを開いてファイルを表示する 方式になる
- それだと、別タブ表示した後に、利用者が自分で「右クリック → ファイル名を指定して保存」的なことをする必要が増え、ユーザー体験が悪くなる
- そもそも Doc コメントに「ファイルをダウンロードする」って書いてんのに、これじゃダウンロードすらしない処理に変わってる(でも、コメントの修正は提案してこない)
- というか、これなら別に HTML の a タグだけで記載完結できるので、わざわざ JS 処理として書かなくてもいいやつ
生成AIは普段使いしてるので、プロンプトでは結構詳細に指示も状況も細かく伝えたけど、とりあえず今はまだこんな状態。こちらが手短に伝えても、本当に欲しい回答を生成する精度になるまでは、まだ時間かかりそう。
原因だけ詳細に説明してもらって、それに対する解決策だけは自分で検討した方が早かった。それなりに知見がある場合は、自分で考えた方がまだ早そう。
SNS でも新記事を告知
この Qiita アカウントでは、技術的にクリティカルなエラーの解消方法や、トレンド技術を具体的に活用するノウハウを中心に記事を公開(考え方などのポエム記事は控えめにしたい)。
X(旧:Twitter)で、ITや生成AIなど技術的な内容しか吐き出さない専用アカウントがあり、新記事の公開時にはこちらでも周知します。
また、Xでは、お気持ち投稿は控え、ひたすらエンジニアや生成AIに関心がある人向けの情報だけを投稿していくつもりです。(※ たまに Udemy や node や Tips などで有料コンテンツの投稿もしますが、悪しからず🙏)
今後の情報にもご興味があれば、お気軽にフォローしてください👇