Flutter Web + Firebase + Cloudinaryで動的OGPをササッと作る
個人で作っているアプリでユーザーのプロフィールをOGPで表示する際に、実践的なユースケースを含めた記事があれば楽に作れたなと思ったので共有させて頂きます。
最終的な成果物はこんな感じです
デプロイ環境のセットアップ
Flutter WebをFirebase Hostingでデプロイする際はこちらの記事で詳しく書かれているので参考にしてみてください。
Cloud Functionsについては公式ドキュメントの参照をお勧めします。(今回はCloud Functions第1世代、言語はTypescriptを使っています)
Cloudinaryとは
Cloudinaryは、画像やビデオを管理、変換、最適化、配信するためのサービスです。ストレージにアップロードした画像のurlに文字のパラメータなどを渡すとそれを元に動的に画像を生成してくれるので、OGPの画像を作る際にとても便利です。
全体の構成
図で示すとこんな感じです。
クライアントからFirebase Hostingとの通信はセットアップで既に構築されているとして、必要な作業は以下の二つになります。
- Firebase Hostingに特定のpathにリクエストがあった際に、特定のCloud Functionにリダイレクトするように設定する。
- リダイレクト先のCloud FunctionでFirestoreからデータを取得して、それらをベースとしたmetaタグを含んだHTMLファイルをクライアントに返す。
Firebase HostingからCloud Functionsにリダイレクトする
Flutterアプリのfirebase.jsonにて、"rewrites"に該当する箇所でリダイレクトの設定を行います。 今回は、/${userId}
のようなpathがきた場合にそのuserId
に関連するOGPを返す場合を想定するものとします。
{
"hosting": {
"public": "build/web",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "/",
"destination": "/index.html"
},
{
"source": "/s/**",
"destination": "/index.html"
},
{
"source": "/**",
"function": "ogp-createOgp",
"region": "asia-northeast1"
},
]
}
}
-
"/"
は静的なページなのでfunctionsを経由しないように設定しています。 -
"/s/**"
は、functionを経由したHTMLがscriptでwindow.location="/s/${path}"
を実行するため設定しています。これを同じにしてしまうと、そのpathがまたfunctionを経由するため無限ループになってしまいます。routingはこのpathにもページが表示されるように設定するようにしてください。 -
"/**"
で指定する"function"
の名前は、後にデプロイするfunctionの名前と一致するようにしてください。"region"
はデフォルト値が"us-central1"
なので、functionを"asia-northeast1"
でデプロイしている場合はこちらの値を指定する必要があります。
Cloud FunctionsからOGPを付けたHTMLを返す
先ほどのfirebase.jsonで指定されていたfunctionです。
export const createOgp = functions.region('asia-northeast1').https.onRequest(async (req, res) => {
const path = req.path.split('/')[1];
/// この関数の内部は本題と関係ないので記述を省略
const user = await fetchUserFromCustomId(path);
const title = user?.nickname != null ? `${user.nickname}の本棚` : `ブックミー`;
const description = `ブックミーは、読書仲間と本が共有できる新しいSNSです。`;
const imageUrl = await generateOgpImageUrl(user.nickname);
try {
/// キャッシュを設定
/// https://firebase.google.com/docs/hosting/manage-cache?hl=ja#set_cache-control
res.set('Cache-Control', 'public, max-age=600, s-maxage=600');
const html = createHtml(path, title, description, imageUrl);
res.status(200).send(html);
} catch (error) {
res.status(404).send('404 Not Found');
}
});
const generateOgpImageUrl = async (nickname?: nickname | null) => {
/// 該当するユーザーがいなければデフォルトのOGP画像を返す
if (!nickname) {
return 'https://res.cloudinary.com/hogehoge/image/upload/v1680961866/bookme_default_ogp_evwiuh.png';
}
const text = `${nickname}の本棚`;
const ogpImageUrl = `https://res.cloudinary.com/hogehoge/image/upload/l_text:Sawarabi%20Gothic_50_bold_center:${text},co_rgb:333,w_800,h_200,c_fit/v1680959422/bookme_ogp_jccxni.png`;
return ogpImageUrl;
};
const createHtml = (path: string, title: string, description: string, imageUrl: string) => {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>ブックミー</title>
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="ブックミー">
<meta property="og:image" content="${imageUrl}">
<meta property="og:image:secure" content="${imageUrl}">
<meta name="twitter:site" content="${title}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
</head>
<body>
<script type="text/javascript">window.location="/s/${path}";</script>
</body>
</html>
`;
};
-
generateOgpImage
ではCloudinaryでアップロードした画像URLに文字と文字のフォントや位置などの情報を渡して、それを元に動的な画像を返してくれるURLを作ってます。 -
createHTML
では表示したいmetaタグと共にwindow.location="/s/${path}";
というscriptを渡して、元々の/index.html
が読み込まれるように設定します。
これらがちゃんとデプロイされればちゃんと動くはずです!完成!
おわりに
functionを経由する際にpathが分かれてしまうのが嫌な場合はbuild/web/index.html
をベタ書きしたものを使うという手もあります。どちらにせよ少し邪道な感じはしてしまいますが、flutter webを使う以上ここら辺は綺麗に作れないというのが自分なりの結論です。
参考にした記事
Discussion