虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Fresh(Deno)で 画像投稿サイトを作ってみよう! with Cloudflare x Tora Viewer

本記事は虎の穴ラボAdvent Calendar 2022-QiitaDeno Advent Calendar 2022の15日目の記事です。

14日目は虎の穴ラボ デザインチーム による「デザインチームが自ら企画&開発に挑戦?!クリエイター向け新サービス「デジタルサインカード」をつくってみた」が投稿されました。


皆さん、こんにちは。
先日一目惚れしたので、約15年ぶりに箱でLEGOを買いました。おっくんです。

今回は、Deno 向け Web フレームワーク Freshを基盤に、Cloudflare Images と 虎の穴ラボから公開している画像ビューア― のOSS Tora Viewer を使って簡単な画像投稿サイトを作ります。

動いているものは次のようになります。

この記事で取り扱うのは、大きくは3つの機能です。

  • Fresh で画像アップロードする
  • 画像一覧表示と、Tora Viewer で画像を表示させる
  • 画像アップロードと、画像一覧コンポーネントを連携させる

それでは、前提をいくつか整えておく必要が有るので、そちらから始めていきます。

前提準備

先に取り上げた3つの機能の前提として、次の準備が必要です。

1. Cloudflare のアカウント作成と Cloudflare Images のサブスク契約

上記の通りです。 Cloudflare Images の契約が必要です。 今回の確認を進めていく中では、一番安い5$/月のプランで十分です。

2. Cloudflare Images のバリアントを作成

画像投稿サイトを作成するに当たり、一覧画面用に小さく変換した画像を使用します。 Cloudflare Images はバリアントを作成することで、変換された画像を提供できます。

mini という名称で、幅:100px 高さ:100px Fit: Crop を設定したバリアントを作成しておきます。

3. データベースの用意

アップロードした画像本体は Cloudflare Images で管理しますが、画像と紐づいたIDを別途管理することが必要です。

この ID を今回は supabase(postgres) で管理し、登録と参照は、supabase edge functions を介して行います。 テーブル構造と、supabase edge functions のソースは次の通りです。

[テーブル構造]

キー フィールド型 説明
id serial プライマリキー
object_id text Cloudflare Images にアップロードした画像のキー
created_at TIMESTAMP 作成時刻
updated_at TIMESTAMP 更新時刻

[supabase/functions/image_posts_api/index.ts]

import { createClient } from "https://esm.sh/@supabase/supabase-js@^1.33.2";
import { serve } from "https://deno.land/std@0.131.0/http/server.ts";
import { Router } from "https://deno.land/x/acorn/mod.ts";

export const supabaseClient = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_ANON_KEY")!,
);

const router = new Router();

router.get("/image_posts_api/images", async (_ctx) => {
  const images = await supabaseClient.from("images").select(
    "id, object_id",
  );

  if (images.error) {
    console.error(images.error);
    throw new Error();
  }

  return { images: images.data };
});

router.post("/image_posts_api/images", async (ctx) => {
  const params = (await ctx.body()) as {
    object_id: string;
  };

  const { data, error } = await supabaseClient.from("images").insert([
    params,
  ]).single();

  if (error) {
    console.error(error);
    throw new Error();
  }

  return { ...data };
});

await serve(
  (req) => {
    console.info(req);
    return router.handle(req);
  },
);

supabase edge functions の実装については過去の記事で詳しく触れているので、こちらを参照ください。

toranoana-lab.hatenablog.com

機能実装

Fresh で画像アップロードする

Fresh で画像アップロードをするに当たり、インタラクティブなコンポーネントとして実装が必要です。

Fresh では一般に SSR されますが、islands/ 以下に置いたコンポーネントは、クライアントレンダリングされるインタラクティブな コンポーネントになります。

実装は、次のようにしました。

[app/islands/Uploader.tsx]

import { JSX } from "preact";
import { useEffect, useRef } from "preact/hooks";
import { mode } from "../util/signal.ts";
import { SimpleDropzone } from "simple-dropzone";

export default function Uploader(_props: JSX.HTMLAttributes) {
  const inputRef = useRef(null);
  const dropzoneRef = useRef(null);

  const upload = async (file: File) => {
    const result = await fetch("/api/get_temp_post_url");
    const resultJson = await result.json();
    const formData = new FormData();
    formData.append("file", file);

    const uploadResult = await fetch(resultJson.url, {
      method: "POST",
      body: formData,
    });

    const uploadedResultJson = await uploadResult.json();

    await fetch("/api/image_updated", {
      method: "POST",
      body: JSON.stringify(uploadedResultJson),
    });

    inputRef.current.value = "";

    mode.value = 1;
  };

  useEffect(() => {
    const dropCtrl = new SimpleDropzone(dropzoneRef.current, inputRef.current);
    dropCtrl.on("drop", ({ files }: { files: Map<number, File> }) => {
      for (let [_key, value] of files) {
        upload(value);
      }
    });
  }, []);

  return (
    <div class="container">
      <div id="dropzone" ref={dropzoneRef}>
        <label class="flex justify-center w-full h-32 px-4 transition bg-white border-4 border-gray-300 border-dashed rounded-lg hover:border-gray-600">
          <div class="flex flex-col items-center m-2">
            <div>
              <ion-icon style={"font-size: 64px;"} name="cloud-upload-outline">
              </ion-icon>
            </div>
            <div>
              <span class="font-medium text-gray-600">
                Drop or Select
              </span>
            </div>
          </div>
          <input type="file" ref={inputRef} class="hidden" multiple />
        </label>
      </div>
    </div>
  );
}

画像の投稿には ドラッグ&ドロップ での操作をしたかったので、simple-dropzoneを使用しました。

www.npmjs.com

useEffect により呼び出された初回のみ、simple-dropzone のセットアップ。 画像がドロップされると、アップロード処理本体の upload() が呼び出されます。

このコンポーネントから、2つのAPIが呼び出されています。

  • アップロード用のURLの取得
  • アップロード後に発行された情報の登録

これらの実装は次の通りです。

アップロード用URLの取得

Cloudflare の direct_upload エンドポイントを呼び出し、一回限りのアップロードURLを発行します。

参考: Cloudflare Docs - Cloudflare Images - Upload images - Direct Creator Upload

[app/routes/api/get_temp_post_url.ts]

import { HandlerContext } from "$fresh/server.ts";
import { envConfig } from "../../util/config.ts";

const getUploadUrl = async () => {
  const formData = new FormData();
  formData.append("requireSignedURLs", "true");
  formData.append("metadata", '{"key":"value"}');

  const body = formData;

  const result = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${envConfig.CF_ACCOUNT_ID}/images/v2/direct_upload`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${envConfig.CF_AUTH_TOKEN}`,
      },
      body,
    },
  );

  if(!result.ok){
    throw new Error("")
  }

  const resultJson = await result.json();

  if(!resultJson.result.uploadURL || !resultJson.result.uploadURL){
    throw new Error("")
  }

  return resultJson.result.uploadURL;
};

export const handler = async (
  _req: Request,
  _ctx: HandlerContext,
): Promise<Response> => {
  const url = await getUploadUrl();

  return Response.json({ url });
};

アップロード後に発行された情報の登録

supabase edge functions に用意したAPIに情報を送っています。

[app/routes/api/image_updated.ts]

import { HandlerContext } from "$fresh/server.ts";
import { envConfig } from "../../util/config.ts";

export const handler = async (
  req: Request,
  _ctx: HandlerContext,
): Promise<Response> => {
  const requestJson = await req.json();

  const result = await fetch(
    `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/images`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        object_id: requestJson.result.id,
      }),
    },
  );

  if (!result.ok) {
    return Response.json({ status: false });
  }

  const resultJson = await result.json();

  if (!resultJson || !resultJson.id) {
    return Response.json({ status: false });
  }

  return Response.json({ status: true });
};

ここまでの実装で画像がアップロードできるようになります。

画像一覧表示と、Tora Viewer で画像を表示させる

先のコンポーネントとAPIの実装によりアップロードされた画像を、表示していきます。

こちらも Tora Viewer を呼び出す動的なコンポーネントですので、islands/ 以下にコンポーネントを作成します。

Tora Viewer の詳細は、こちらの記事触れています。

toranoana-lab.hatenablog.com

[app/islands/Images.tsx]

import { JSX } from "preact";
import { type StateUpdater, useEffect, useState } from "preact/hooks";
import { mode } from "../util/signal.ts";
import toraViewer from "tora-viewer";
import { clearAllBodyScrollLocks, disableBodyScroll } from "body-scroll-lock";

interface Image {
  url: string;
  key: string;
}

// public バリアントで画像一覧を読み出し
const fetchOriginalImages = async (setData: StateUpdater<Image[]>) => {
  const res = await fetch("/api/get_images?type=public");
  const resJson = await res.json();

  setData(resJson.images);
};

// mini バリアントで画像一覧を読み出し
const fetchMiniImages = async (setData: StateUpdater<Image[]>) => {
  const res = await fetch("/api/get_images?type=mini");
  const resJson = await res.json();

  setData(resJson.images);
};

export default function Images(_props: JSX.HTMLAttributes) {
  const [originalImages, setOriginalImages] = useState<Image[]>([]);
  const [miniImages, setMiniImages] = useState<Image[]>([]);

  useEffect(() => {
    fetchOriginalImages(setOriginalImages);
    fetchMiniImages(setMiniImages);
    mode.value = 0;
  }, [mode.value]);

  const openViewer = (index: number) => {
    const viewer = toraViewer(
      originalImages.map((image: Image, index: number) => {
        return { url: image.url, thumbnailUrl: miniImages[index].url };
      }),
      {
        pageStyle: "normal",
      },
    );
    viewer.goTo(index);
    disableBodyScroll(document.querySelector("body"));

    // ビューア―の閉じるのに合わせてスクロールロックの解除処理を設定する
    const originDispose = viewer.dispose;
    const boundDispose = originDispose.bind(viewer);
    const newDispose = () => {
      clearAllBodyScrollLocks();
      boundDispose();
    };
    viewer.dispose = newDispose;
  };

  return (
    <div class="flex justify-center container">
      <div class="flex flex-wrap w-full justify-center container">
        {!miniImages ? "" : miniImages.map((image: Image, index: number) => (
          <div
            class="my-2 px-1 w-32 w-full"
            key={`key-${image.key}`}
            style="background-image: url(/logo.svg); background-repeat: no-repeat; background-position: center center;"
          >
            <img
              src={image.url}
              onClick={() => openViewer(index)}
              class="w-32 h-32 mx-auto rounded-lg border-gray-300 border-4 hover:rotate-6"
            />
          </div>
        ))}
      </div>
    </div>
  );
}

このコンポーネントは、/api/get_images にアクセスした結果を元に、画像の一覧と Tora Viewer で表示する画像URL情報を呼び出します。

Tora Viewer は、簡単な設定で画像ビューアーが立ち上がります。 ビュアー背面のスクロールロックは、標準で導入されていないので、body-scroll-lock を導入しています。
こちらを利用し、ビュアーの開閉に連動してスクロールロックが行われるようにします。

const viewer = toraViewer(
  originalImages.map((image: { url: string }, index: number) => {
    return { url: image.url, thumbnailUrl: miniImages[index].url };
  }),
  {
    pageStyle: "normal",
  },
);

viewer.goTo(index);
disableBodyScroll(document.querySelector("body"));

// ビューアーを閉じる dispose が呼ばれたとき、元の dispose と スクロールロックの解除が呼び出されるようにする
const originDispose = viewer.dispose;
const boundDispose = originDispose.bind(viewer);
const newDispose = () => {
  clearAllBodyScrollLocks();
  boundDispose();
};
viewer.dispose = newDispose;

開いた処理に合わせてスクロールロックをすることは容易ですが、閉じる処理には工夫が必要です。 閉じる処理を行うメソッドが呼び出し可能なので、元のメソッドをバインドし、新しいメソッドを外から渡すことで、ユーザーのビューアーの「閉じる」操作に合わせてスクロールロックを解除させています。

画像一覧の情報は、/api/get_images から呼び出します。 実装は次の通りです。

[app/routes/api/get_images.ts]

import { HandlerContext } from "$fresh/server.ts";
import { generateSignedUrl } from "../../util/cloudflare-images.ts";
import { envConfig } from "../../util/config.ts";
import { hash } from "huid/mod.ts";
export const handler = async (
  req: Request,
  _ctx: HandlerContext,
): Promise<Response> => {
  const urlSearchParams = new URLSearchParams(new URL(req.url).search);
  const imageType = urlSearchParams.get("type");

  if (!imageType) {
    throw new Error("Reqire query 'type='");
  }

  const result = await fetch(
    `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/images`,
    {
      headers: {
        Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`,
        "Content-Type": "application/json",
      },
    },
  );

  const data = await result.json();

  const signedImageUrls = await Promise.all(
    data.images.map(async (i: { id: number; object_id: string }) => {
      return {
        url: await generateSignedUrl(
          envConfig.CF_ACCOUNT_HASH,
          i.object_id,
          imageType,
          envConfig.CF_EXPIRE_SECONDS,
          envConfig.CF_SIGN_KEY,
        ),
        key: hash(i.object_id),
      };
    }),
  );

  return Response.json({ images: signedImageUrls.reverse() });
};

画像は署名付きのURLを用いた場合のみ読み出せるようになっています。 generateSignedUrl として切り出しています。詳細は次の通りです。

[app/util/cloudflare-images.ts]

function bufferToHex(buffer: Uint8Array) {
  return [...buffer].map((x) => x.toString(16).padStart(2, "0"))
    .join(
      "",
    );
}

export async function generateSignedUrl(
  accountHash: string,
  objectId: string,
  variant: string,
  expireSeconds: number,
  signKey: string,
) {
  const unsignedUrl = new URL(
    `https://imagedelivery.net/${accountHash}/${objectId}/${variant}`,
  );

  const encoder = new TextEncoder();
  const secretKeyData = encoder.encode(signKey);
  const key = await crypto.subtle.importKey(
    "raw",
    secretKeyData,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );

  const expiry = Math.floor(Date.now() / 1000) + expireSeconds;
  unsignedUrl.searchParams.set("exp", expiry.toString());

  const stringToSign = unsignedUrl.pathname + "?" +
    unsignedUrl.searchParams.toString();
  const mac = await crypto.subtle.sign(
    "HMAC",
    key,
    encoder.encode(stringToSign),
  );
  const sig = bufferToHex(new Uint8Array(mac));

  unsignedUrl.searchParams.set("sig", sig);

  return unsignedUrl.href;
}

画像アップロードと、画像一覧コンポーネントを連携させる

ここまでで、画像のアップロードと一覧表示、Tora Viewer での表示が達成されていますが、これらがどのように連動しているのか解説します。

先に作成した2つのコンポーネントは、次のように呼び出されています。

[app/routes/index.tsx]

import { Head } from "$fresh/runtime.ts"
import Uploader from "../islands/Uploader.tsx";
import Images from "../islands/Images.tsx";
 
export default function Home() {
  return (
    <div class="p-4 mx-auto max-w-screen-md">
      <Head>
        <script type="module" src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js"></script>
        <script nomodule src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.js"></script>
      </Head>
      <Uploader />
      <Images />
    </div>
  );
}

Uploader と Images の間で共有されているものはなさそうに見えますが、それぞれのコンポーネントから共通して参照されているものが有ります。

それが、Preact が状態管理のために使う機能として提供している Signals です。
参考: PREACT - Essentials - Signals

preactjs.com

今回の実装では、限りなくシンプルに使っています。

[app/util/signal.ts]

import { signal } from "@preact/signals";

export const mode = signal(0);

この mode を Uploader と Images で共有しています。

[app/islands/Uploader.tsx - signals使用部分]

import { mode } from "../util/signal.ts";

export default function Uploader(_props: JSX.HTMLAttributes) {

  const upload = async (file: File) => {

    // 省略

    // signals の値を変更し、画像一覧の呼び出しをトリガーする
    mode.value = 1;
  };


  return (
    <div class="container">
      {// 省略}
    </div>
  );
}

[app/routes/api/image_updated.ts - signals使用部分]

import { mode } from "../util/signal.ts";

export default function Images(_props: JSX.HTMLAttributes) {
  const [originalImages, setOriginalImages] = useState([]);
  const [miniImages, setMiniImages] = useState([]);

 // signals を参照して、画像情報の再読み込みする
  useEffect(() => {
    fetchOriginalImages(setOriginalImages);
    fetchMiniImages(setMiniImages);
    mode.value = 0;
  }, [mode.value]);

  return (
    <div class="flex justify-center container">
      {// 省略}
    </div>
  );
}

このような実装で、本記事冒頭の動画のように動かすことができます。

全体感

ここまでの実装作成されたディレクトリは次のようになります。

$ tree
.
|-- Dockerfile
|-- LICENSE.md
|-- README.md
|-- app
|   |-- README.md
|   |-- components
|   |-- db
|   |   |-- migrations
|   |   |   |-- 20221112200345_create_images.ts
|   |   |   `-- 20221113055320_create_images_trigger.ts
|   |   `-- seeds
|   |-- deno.json
|   |-- deno.lock
|   |-- dev.ts
|   |-- fresh.gen.ts
|   |-- import_map.json
|   |-- islands
|   |   |-- Images.tsx
|   |   `-- Uploader.tsx
|   |-- main.ts
|   |-- nessie.config.ts
|   |-- routes
|   |   |-- api
|   |   |   |-- get_images.ts
|   |   |   |-- get_temp_post_url.ts
|   |   |   `-- image_updated.ts
|   |   `-- index.tsx
|   |-- static
|   |   |-- favicon.ico
|   |   `-- logo.svg
|   |-- twind.config.ts
|   `-- util
|       |-- cloudflare-images.ts
|       |-- config.ts
|       `-- signal.ts
|-- docker-compose.yml
|-- node_modules
|-- package-lock.json
|-- package.json
`-- supabase
    |-- config.toml
    |-- functions
    |   `-- image_posts_api
    |       `-- index.ts
    `-- migrations

お手元で動かしてみたい場合には、こちらで公開していますので、cloneして動かしてみて下さい。

github.com

注意事項: preactの配信元CDNのesm.shがビルドした結果が、deno.lockで記載されたものと一致せずエラーになることがあるようです。
この場合、deno.lockを一度削除をお願いします。

まとめ

Fresh をベースに Cloudflare と Tora Viewer を使用して画像投稿サイトを作ってみました。
今回の実装に、ローディング時のアニメーションや、ユーザーの管理など追加していくことで、画像投稿サイトとして十分公開できうるものになるかと思います。

Preact Signals を使うことで、Fresh の islands 間の連携が取れるので、ステータスを参照し合うために、1枚コンポーネントをかませるようなことをせずに済みます。
特にイイのは、この機能が Preact から提供されている点です。サードパーティでもうれしいですが本体に持ってくれていると信頼感が違いますね。

明日の投稿は、H.K さんの「レビュー者におすすめ!『読みやすいコードのガイドライン』を読んでの感想」です。

P.S.

採用情報
■募集職種
yumenosora.co.jp

カジュアル面談も随時開催中です
■お申し込みはこちら!
news.toranoana.jp

■ToraLab.fmスタートしました!
メンバーによるPodcastを配信中!
是非スキマ時間に聞いて頂けると嬉しいです。
anchor.fm
■Twitterもフォローしてくださいね!
ツイッターでも随時情報発信をしています
twitter.com