静的 Web アプリから Azure Blob Storage に画像をアップロードするメモ

## 概要
Microsoft Learnの[静的 Web アプリから Azure Blob Storage に画像をアップロードする](https://learn.microsoft.com/ja-jp/training/modules/blob-storage-image-upload-static-web-apps/)を[Static Web Apps](https://learn.microsoft.com/ja-jp/azure/static-web-apps/) で行おうとしたが、 私がFunctions に Typescript を使っていたため、[utils.js](https://github.com/MicrosoftDocs/mslearn-blob-storage-image-upload-static-web-app/blob/main/credentials/utils.js)がコピペで動かず詰んだ。

[JavaScript を使用してイメージを Azure Storage Blob にアップロードする](https://learn.microsoft.com/ja-jp/azure/developer/javascript/tutorial/browser-file-upload-azure-storage-blob?tabs=github-codespaces) の[サンプルコード](https://github.com/hibohiboo/azure-typescript-e2e-apps/tree/main/azure-upload-file-to-storage)はTypescriptであり、こちらを使ったら動作させることができたので備忘録として残す。

## blobの設定
Microsoft Learnの手順ではデフォルトの設定でと言っている部分だが、論理的な削除は不要な使い方をする予定なので、チェックを外しておく。（ファイルを消しても論理削除期間中は課金されるのが嫌なので）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/05d35e4e-6d18-daf5-a365-3894b92ceba1.png)


## 書き換え
### バックエンド
[utils.jsを使っていた関数](https://github.com/MicrosoftDocs/mslearn-blob-storage-image-upload-static-web-app/blob/main/credentials/index.js)を[対応する関数](https://github.com/hibohiboo/azure-typescript-e2e-apps/blob/main/azure-upload-file-to-storage/api/src/functions/sas.ts)に書き換えた。

```ts:api/src/functions/credentiols.ts
import {
  HttpRequest,
  HttpResponseInit,
  InvocationContext,
  app,
} from '@azure/functions';
import { generateSASUrl } from '../lib/azure-storage.js';

export async function getGenerateSasToken(
  request: HttpRequest,
  context: InvocationContext,
): Promise<HttpResponseInit> {
  context.log(`Http function processed request for url "${request.url}"`);

  try {
    if (
      !process.env?.Azure_Storage_AccountName ||
      !process.env?.Azure_Storage_AccountKey
    ) {
      return {
        status: 405,
        jsonBody: 'Missing required app configuration',
      };
    }

    const containerName = request.query.get('container') || 'images';
    const fileName = request.query.get('file') || 'nonamefile';
    const permissions = request.query.get('permission') || 'w';
    const timerange = parseInt(request.query.get('timerange') || '10'); // 10 minutes

    context.log(`containerName: ${containerName}`);
    context.log(`fileName: ${fileName}`);
    context.log(`permissions: ${permissions}`);
    context.log(`timerange: ${timerange}`);

    const url = await generateSASUrl(
      process.env?.Azure_Storage_AccountName,
      process.env?.Azure_Storage_AccountKey,
      containerName,
      fileName,
      permissions,
    );

    return {
      jsonBody: {
        url,
      },
    };
  } catch (error) {
    return {
      status: 500,
      jsonBody: error,
    };
  }
}

app.http('credentials', {
  methods: ['GET', 'POST'],
  authLevel: 'anonymous',
  handler: getGenerateSasToken,
});

```

[ストレージアカウントの接続文字列の取得](https://learn.microsoft.com/ja-jp/training/modules/blob-storage-image-upload-static-web-apps/5-exercise-serverless)でコピーした文字列「`DefaultEndpointsProtocol=https;AccountName=アカウント名;AccountKey=アカウントキー;EndpointSuffix=core.windows.net`」から、必要な値を抜き出して設定ファイルに転記する。

```json:api/local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage":"",
    "Azure_Storage_AccountName":"アカウント名",
    "Azure_Storage_AccountKey": "アカウントキー",
    "FUNCTIONS_WORKER_RUNTIME": "node",
    "AzureWebJobsFeatureFlags": "EnableWorkerIndexing"
  },
  "Host": {
    "CORS": "*"
  }
}
```

<details><summary>余談:オリジンの末尾に/を入れたことにより403</summary>

手順では、「許可されたオリジン」は「*」を指定したが、色気をだして指定してみたところ、末尾に`/`を入れてしまって、この後アップロードを行う時に失敗することとなった。。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/2c497a1b-f4f7-b15c-2529-c1eca10df6a0.png)

</details>

### フロントエンド

[App.tsx](https://github.com/hibohiboo/azure-typescript-e2e-apps/blob/main/azure-upload-file-to-storage/app/src/App.tsx)の処理をhooksに移して書き換えている。awaitを使ったほうが好きなので、handlerもasyncを使った非同期関数に修正。また、今回はアップロードだけに絞るため、一覧を取得する処理は削除した。

```ts:app/src/hooks/useImageUploaderPageHooks.ts
/* eslint-disable turbo/no-undeclared-env-vars */
import { BlockBlobClient } from '@azure/storage-blob';
import { convertFileToArrayBuffer } from '@yakumi-app/domain/storageAccount/convert-file-to-arraybuffer';
import { ChangeEvent, useState } from 'react';

export const useImageUploaderPageHooks = () => {
  const containerName = `images`;
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [sasTokenUrl, setSasTokenUrl] = useState<string>('');
  const [uploadStatus, setUploadStatus] = useState<string>('');

  const handleFileSelection = (event: ChangeEvent<HTMLInputElement>) => {
    const { target } = event;

    if (!(target instanceof HTMLInputElement)) return;
    if (
      target?.files === null ||
      target?.files?.length === 0 ||
      target?.files[0] === null
    )
      return;

    setSelectedFile(target?.files[0]);

    // reset
    setSasTokenUrl('');
    setUploadStatus('');
  };

  const handleFileSasToken = async () => {
    const permission = 'w'; //write
    const timerange = 5; //minutes

    if (!selectedFile) return;
    try {
      const requestUrl = `${import.meta.env.VITE_API_SERVER ?? ''}/api/credentials?file=${encodeURIComponent(
        selectedFile.name,
      )}&permission=${permission}&container=${containerName}&timerange=${timerange}`;
      const response = await fetch(requestUrl, {
        method: 'POST',
        mode: 'cors',
        headers: {
          'Content-Type': 'application/json',
        },
      });
      const data = await response.json(); // JSON のレスポンスをネイティブの JavaScript オブジェクトに解釈
      const { url } = data;
      setSasTokenUrl(url);
    } catch (error) {
      console.warn(error);
      if (error instanceof Error) {
        const { message, stack } = error;
        setSasTokenUrl(`Error getting sas token: ${message} ${stack || ''}`);
      } else {
        setUploadStatus(error as string);
      }
    }
  };

  const handleFileUpload = async () => {
    if (sasTokenUrl === '') return;
    try {
      const fileArrayBuffer = await convertFileToArrayBuffer(
        selectedFile as File,
      );

      if (
        fileArrayBuffer === null ||
        fileArrayBuffer.byteLength < 1 ||
        fileArrayBuffer.byteLength > 256000
      )
        return;

      const blockBlobClient = new BlockBlobClient(sasTokenUrl);
      await blockBlobClient.uploadData(fileArrayBuffer);

      setUploadStatus('Successfully finished upload');

    } catch (error) {
      console.warn(error);
      if (error instanceof Error) {
        const { message, stack } = error;

        setUploadStatus(
          `Failed to finish upload with error : ${message} ${stack || ''}`,
        );
      } else {
        setUploadStatus(error as string);
      }
    }
  };

  return {
    handleFileSelection,
    handleFileUpload,
    handleFileSasToken,
    uploadStatus,
  };
};
```

```tsx:app/pages/ImageUploaderPage.tsx
import { useImageUploaderPageHooks } from '@yakumi-app/hooks/useImageUploaderPageHooks';

function ImageUploaderPage() {
  const vm = useImageUploaderPageHooks();

  return (
    <div style={{ margin: '1rem' }}>
      <h1>画像アップロード</h1>
      <input
        type="file"
        id="image"
        name="image"
        accept="image/png, image/jpeg"
        onChange={vm.handleFileSelection}
      ></input>
      <button onClick={vm.handleFileSasToken}>SASトークン取得</button>
      <button onClick={vm.handleFileUpload}>アップロード</button>
      {vm.uploadStatus}
    </div>
  );
}

export default ImageUploaderPage;
```

```app/.env.local
VITE_API_SERVER=http://localhost:7071
```

## ローカル実行

```
cd api && npm run start
```

```
cd app && npm run dev
```

* 無事ファイルをアップロードできた
* 日本語を含むファイルもアップロードできたが、「スクリーンショット 2023-10-04 001528.png」はアップロードできなかった。空白文字などが問題？いったん備忘としてこれ以上の調査はここでは行わない。

## デプロイ後の設定

環境変数の設定を忘れないこと（忘れた）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/df6caeee-a882-d046-b10a-d1a829893c3f.png)

## フォルダを切ってファイルを配置

現状のコードだと、コンテナ―直下に配置される。
例えば、`upload`フォルダに入れるようにするには下記のように`フォルダ名/ファイル名`とすればよい。


```diff:app/src/hooks/useImageUploaderPageHooks.ts
  const handleFileSasToken = async () => {
    const permission = 'w'; //write
    const timerange = 5; //minutes
    if (!selectedFile) return;

+   const fileName = `upload/${selectedFile.name}`;

    try {
      const requestUrl = `${import.meta.env.VITE_API_SERVER ?? ''}/api/credentials?file=${encodeURIComponent(
-        selectedFile.name,
+        fileName,        
      )}&permission=${permission}&container=${containerName}&timerange=${timerange}`;
      const response = await fetch(requestUrl, {
        method: 'POST',
        mode: 'cors',
        headers: {
          'Content-Type': 'application/json',
        },
      });
```