🌐

Flutter Web + Firebase + Cloudinaryで動的OGPをササッと作る

2023/07/24に公開

個人で作っているアプリでユーザーのプロフィールをOGPで表示する際に、実践的なユースケースを含めた記事があれば楽に作れたなと思ったので共有させて頂きます。

最終的な成果物はこんな感じです

http://bookm.me/santa

デプロイ環境のセットアップ

Flutter WebをFirebase Hostingでデプロイする際はこちらの記事で詳しく書かれているので参考にしてみてください。
https://medium.com/flutter/must-try-use-firebase-to-host-your-flutter-app-on-the-web-852ee533a469

Cloud Functionsについては公式ドキュメントの参照をお勧めします。(今回はCloud Functions第1世代、言語はTypescriptを使っています)
https://firebase.google.com/docs/functions/get-started

Cloudinaryとは

Cloudinaryは、画像やビデオを管理、変換、最適化、配信するためのサービスです。ストレージにアップロードした画像のurlに文字のパラメータなどを渡すとそれを元に動的に画像を生成してくれるので、OGPの画像を作る際にとても便利です。
https://cloudinary.com/

全体の構成

図で示すとこんな感じです。

クライアントからFirebase Hostingとの通信はセットアップで既に構築されているとして、必要な作業は以下の二つになります。

  1. Firebase Hostingに特定のpathにリクエストがあった際に、特定のCloud Functionにリダイレクトするように設定する。
  2. リダイレクト先の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を使う以上ここら辺は綺麗に作れないというのが自分なりの結論です。

参考にした記事

https://catnose.me/notes/cloudinary-dynamic-ogp-image
https://cloudinary.com/cookbook
https://firebase.google.com/docs/hosting/functions?hl=ja
https://firebase.google.com/docs/hosting/manage-cache?hl=ja
https://qiita.com/stin_dev/items/41ac4acb6ee7e1bc2d50
https://zenn.dev/flutteruniv_dev/articles/8ee86f7b4b9c61

Discussion