動的OGPもどきをAzure Storage Account で行ったメモ

## 概要
Azureの静的ページを使ってSPAを公開している。
ユーザの個別ページに動的OGPを設定したいが、LambdaのEdgeFunctionsのようなものがないので、OGPの設定をしたページをStorageAccountに保存し、SPAにリダイレクトするように設定した。

[ソースコード](https://github.com/hibohiboo/Yakumi)

## ソースコード
### OGP用のファイル作成
[meta タグによるリダイレクト](https://developer.mozilla.org/ja/docs/Web/HTTP/Redirections)を採用している。
twitter-cardの設定を、Xに合わせたものにしている。

```ts:app/src/domain/fallMagia/services/persistent/saveCharacterTwitterCard.ts
const SERVICE_NAME = '魔法少女フォールマギア';

interface Args {
  url: string;
  title: string;
  description: string;
  src: string;
}
export const createCharacterTwitterCard = ({
  url,
  title,
  description,
  src,
}: Args) =>
  `
<!doctype html>
<html lang="ja">
  <head prefix="og: http://ogp.me/ns#">
    <meta charset="UTF-8" />
    <title>${title}｜${SERVICE_NAME}</title>
    <meta name="description" content="${description}" />
    <meta name="author" content="hibohiboo">
    <meta name="keywords" content="TRPG,${SERVICE_NAME},キャラクターシート" />
    <meta property="og:type" content="article" />
    <meta property="og:locale" content="ja_JP" />
    <meta property="og:site_name" content="${SERVICE_NAME}">
    <meta property="og:title" content="${title}｜${SERVICE_NAME}" />
    <meta property="og:description" content="${description}" />
    <meta property="og:image" content="${src}" />
    <meta property="og:url" content="${url}" />
    <meta name="x:card" content="summary_large_image" />
    <meta name="x:site" content="@hibohiboo" />
    <meta name="x:creator" content="@hibohiboo" />
    <meta name="x:image:alt" content="${title}｜${SERVICE_NAME}">
    <meta http-equiv="Refresh" content="0; URL=${url}" />
  </head>
  <body></body>
</html>
  `;
```



### アップロード

```ts:app/src/domain/fallMagia/store/slices/fallMagiaCharacterPageSlice.ts
  const directory = `upload/${userPage.id}`;
  // ストレージアカウントにTwitterCardを保存
  await uploadToStorageAccount(
    new File(
      [
        new Blob(
          [
            createCharacterTwitterCard({
              url: `https://${location.hostname}/app/spa/userpage/${userPage.id}`,
              title: userPage.title,
              src: userPage.src,
              description: userPage.description,
            }),
          ],
          { type: 'text/html' },
        ),
      ],
      'twitter-card.html',
      { type: 'text/html' },
    ),
    'twitter-card.html',
    directory,
    {
      blobHTTPHeaders: {
        blobContentType: 'text/html',
      },
    },
  );
  ```
  
```ts:app/src/domain/storageAccount/uploadToStorageAccount.ts
import {
  BlockBlobClient,
  BlockBlobParallelUploadOptions,
} from '@azure/storage-blob';
import { postSASToken } from '../api/crud';
import { convertFileToArrayBuffer } from './convert-file-to-arraybuffer';

const containerName = `$web`;
const permission = 'w'; //write
const timerange = 5; //minutes
const minFileSize = 1; //bytes
const maxFileSize = 5 * 1024 * 1024; // 5M bytes
/**
 * Uploads a file to a storage account web container
 * @param file - The file to upload
 * @param fileName - The name of the file
 * @param directory - The directory to upload the file to
 */
export const uploadToStorageAccount = async (
  file: File,
  fileName: string,
  directory = 'uploaded',
  option?: BlockBlobParallelUploadOptions,
) => {
  const filePath = `${directory}/${fileName}`;
  const sasToken = await getSasTokenUrl(filePath);
  if (!sasToken) {
    console.warn('Failed to get sas token');
    return false;
  }
  try {
    const fileArrayBuffer = await convertFileToArrayBuffer(file as File);

    if (
      fileArrayBuffer === null ||
      fileArrayBuffer.byteLength < minFileSize ||
      fileArrayBuffer.byteLength > maxFileSize
    ) {
      console.warn('Failed to convert file to array buffer');
      return;
    }

    const blockBlobClient = new BlockBlobClient(sasToken);

    await blockBlobClient.uploadData(fileArrayBuffer, option);
    return true;
  } catch (error) {
    if (error instanceof Error) {
      const { message, stack } = error;

      console.warn(
        `Failed to finish upload with error : ${message} ${stack || ''}`,
      );
    } else {
      console.warn(error as string);
    }
    return false;
  }
};

async function getSasTokenUrl(filePath: string) {
  try {
    const response = await postSASToken(
      filePath,
      permission,
      containerName,
      timerange,
    );
    const data = await response.json();
    const { url } = data;
    return url;
  } catch (error) {
    console.warn(error);
    if (error instanceof Error) {
      const { message, stack } = error;
      console.warn(`Error getting sas token: ${message} ${stack || ''}`);
    } else {
      console.warn(error as string);
    }
  }
}
```

```ts:app/src/domain/api/crud.ts

const baseUrl = `${import.meta.env.VITE_API_SERVER ?? ''}/api`;
const apiBaseUrl = '/data-api/rest';
export const postSASToken = async (
  filePath: string,
  permission: string,
  containerName: string,
  timerange: number,
) => {
  const requestUrl = `${baseUrl}/credentials?file=${encodeURIComponent(
    filePath,
  )}&permission=${permission}&container=${containerName}&timerange=${timerange}`;
  const response = await fetch(requestUrl, {
    method: 'POST',
    mode: 'cors',
    headers: {
      'Content-Type': 'application/json',
    },
  });
  return response;
};
```

SASトークン取得のサーバ側の処理は[静的 Web アプリから Azure Blob Storage に画像をアップロードするメモ](https://qiita.com/hibohiboo/items/71d1af715c25707a32fc)と同様。

## 参考
[VueのSPAでの動的OGPをS3+Cloudfront+Lambda@Edgeの構成でcdkを使って実現したメモ](https://qiita.com/hibohiboo/items/244dfb4eec18be1cc285)