📝

Remixで実装するCloudflare Images: 画像のアップロードと削除の実践ガイド

2024/07/29に公開

こちらの記事の続き。
画像のホスティングに Cloudflare Images はいかが?

Cloudflare Imagesに実際に画像をアップロードし、そして削除もしてみた。

ソースコードはこちら。
ANTON072/cloudflare-images-sample

Cloudflare Imagesに画像の保存

前回の記事では、Cloudflare ImagesのDirect Upload APIを利用すると書いたが、Remixを利用する場合はActionからそのまま画像をアップロードできるので不要だった。ごめん。

Cloudflare Imagesのドキュメントの最初に書いてあるこのcurlをJavaScriptに翻訳したらよいだけ。

curl --request POST \
  --url https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1 \
  --header 'Authorization: <API_TOKEN> \
  --header 'Content-Type: multipart/form-data' \
  --form file=@./<YOUR_IMAGE.IMG>

upload.tsx

フォーム部分


...

      <Form
        method="post"
        encType="multipart/form-data"
        className="flex flex-col space-y-5"
      >
        <label>
          <span>Name</span>
          <input type="text" name="name" className="block mt-1 w-full" />
        </label>
        <label>
          <span>Image File</span>
          <input
            type="file"
            name="image"
            className="block mt-1 w-full"
            accept="image/*"
          />
        </label>
        <button
          type="submit"
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        >
          Submit
        </button>
      </Form>

...

encTypeに multipart/form-data を指定しているのがミソ。以前の記事でも解説したが、フォームでファイル送信する場合は multipart/form-data を指定する必要がある。

続いてAction。

import {
  unstable_composeUploadHandlers as composeUploadHandlers,
  unstable_createMemoryUploadHandler as createMemoryUploadHandler,
  unstable_parseMultipartFormData as parseMultipartFormData,
} from "@remix-run/cloudflare";

...

import { cfImagesUploadHandler } from "~/utils/cfImagesUploadHandler";


export async function action({ request, context }: ActionFunctionArgs) {
  const env = context.cloudflare.env;

  const uploadHandler = composeUploadHandlers(
    async ({ name, filename, data, contentType }) => {
      if (name !== "image") return undefined;
      const cfImage = await cfImagesUploadHandler({
        data,
        filename,
        contentType,
        cfAccountId: env.CLOUDFLARE_ACCOUNT_ID,
        cfApiToken: env.CLOUDFLARE_IMAGES_API_TOKEN,
      });

      return cfImage.result.id;
    },
    createMemoryUploadHandler(),
  );

  const formData = await parseMultipartFormData(request, uploadHandler);
  const name = formData.get("name") as string;
  const image = formData.get("image") as string;

  return json<ActionResponse>({ name, image, error: null });
}

Remixは便利な関数を用意してくれている(unstable_composeUploadHandlers)のでそれを使ってデータを受け取る。

cfImagesUploadHandler関数(後で解説する)でアップロードを実行し、レスポンスで画像IDを受け取り、それをそのままフロントに返している。
本来のウェブアプリケーションであれば、この画像IDをDBに保存するなどの処理をするだろう。

cfImagesUploadHandler関数

...

export const cfImagesUploadHandler = async ({
  data,
  filename,
  contentType,
  cfAccountId,
  cfApiToken,
}: Args) => {
  // AsyncIterable<Uint8Array> を Blob に変換
  const chunks = [];
  for await (const chunk of data) {
    chunks.push(chunk);
  }
  const blob = new Blob(chunks, { type: contentType });
  const formData = new FormData();
  formData.append("file", blob, filename);

  const response = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/images/v1`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${cfApiToken}`,
      },
      body: formData,
    },
  );

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.statusText}`);
  }

  return (await response.json()) as ApiResponse;
};

...

Cloudflare ImagesにはBlob形式で送る必要がある。受け取ったデータはAsyncIterable<Uint8Array>形式なので変換した。あとはcurl文を翻訳してアップロードするだけ。

Remixの環境変数の扱いの特殊さにも気をつけたい。

Cloudflare Imagesの画像を削除

アップロードはできたので次は削除。削除に必要なのは画像IDだ。

HTML側はこうなっている。

...

        <Form method="delete" action={`/upload/${actionData.image}`}>
            <button
              type="submit"
              className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            >
              Delete
            </button>
          </Form>

...

Actionは routes/upload.$image_id_.tsx に書いた。なぜこのファイル名なのかは別の記事で。Remixのファイル名ルールは少しややこしいのだ。

なぜファイルを分けたのか?

DeleteメソッドはGET同様にbodyにパラメータを持たせない実装が良しとされている。理由は各自調査。なので、URLに画像IDを持たせるようにしてある。

upload.$image_id_.tsx

import { ActionFunctionArgs, json, redirect } from "@remix-run/cloudflare";

export async function action({ request, params, context }: ActionFunctionArgs) {
  const env = context.cloudflare.env;
  const method = request.method.toLowerCase();

  if (method === "delete") {
    const imageId = params.image_id;
    if (!imageId) {
      return json({ error: "Image ID is required" }, { status: 400 });
    }
    const {
      CLOUDFLARE_ACCOUNT_ID: accountId,
      CLOUDFLARE_IMAGES_API_TOKEN: apiToken,
    } = env;
    const response = await fetch(
      `https://api.cloudflare.com/client/v4
/accounts/${accountId}/images/v1/${imageId}`,
      {
        method: "DELETE",
        headers: {
          Authorization: `Bearer ${apiToken}`,
        },
      },
    );

    if (!response.ok) {
      return json({ error: "Failed to delete image" }, { status: 500 });
    }

    return redirect("/upload");
  }

  return json({ error: "Method not allowed" }, { status: 405 });
}

Cloudflare Images APIにDELETEメソッドを投げているだけだ。

Cloudflare ImagesはPOSTもDELETEも基本的にはフロントエンド側からは実行できないようになっている。なぜかというと、アカウントIDやAPIトークンは見えてはいけない情報だからだ。
フロントエンド側から実行しようとしてもCORSエラーになるはず。

なので必ずサーバーサイドを通して操作をする必要がある。

個人的な感想だが、APIドキュメントがしっかりしているのでかなり利用しやすい印象。コストも安いのでどんどん提案していきたい。

株式会社トゥーアール

Discussion