🦕

弊社お問い合わせフォームのバリデーション判定に Deno + Fresh を使ってみた

2023/01/27に公開

目次

はじめに

アップデイティットの毛利です。

みなさん、Deno 使ってますか?

私は、業務上で振ってきた変な文字列のデータを更に変な型式に変換するような、書き捨てコードの実行環境として主に活用してます。
ちょっと前までは、Java や Node.js で書き捨て環境を切ったりしてましたが、それらと比べても便利です。

さて、少し前の話ですが、弊社のホームページを置いてあるマシンの OS が EOL を迎えるため、サーバー移行をしました。
その際、お問い合わせフォーム用に使っていた POST-MAIL も移植する必要があったのですが、検討の結果 Deno を使って自作することにしました。

今回は、その時どういったことをしたかを書き残したいと思います。

動機、環境など

本記事は、主に Deno + Fresh で自作したお問い合わせフォーム用バリデーションツールの話を、内容を絞って紹介します。
基本的に開発を進めるうえでのポイントにのみ解説しますが、最終的なツールの中身一式は Github などに公開しません、予めご了承ください。

この記事のゴール

  • お問い合わせフォーム用のバリデーション機能・内容確認後に送信する機能を Deno で開発
  • サーバー移行に伴って、従来使用していたツールから自作ツールへ置き換えて稼働
    • 追加要件としてスパム業者からのお問い合わせを弾けるようにする

動機

そもそもとして、旧サーバーで稼働していた POST-MAIL のバージョンがかなり古く、単純移植だと動作やセキュリティの怪しいところがありました。
また、最新バージョン(執筆時点で V9.5)にするにも、設定値レベルでいろいろ変わっていたので、マイグレーションにどれくらいかかるか見通しが立ちませんでした。
(この段階で見切りをつけたので、もう少し深く調べれば何かしら情報はあったのかもしれません。)

どうしたものかと悩んでいましたが、「好きにやっていいよ」という上からの有難いお言葉があったので、「自作してしまおう、ついでに Deno でやってしまおう」と決めました。

Deno + Fresh でなければならない理由はありません。
強いて言えば

  • TypeScript ネイティブで書ける。
  • ビルドがないので、ちょっと直して再 RUN する時のダウンタイムが非常に小さく、気軽に更新できる。
  • Fresh の仕様として、パス管理の見通しが良い(Next.js 的)。

などが挙げられます。
CGI の代替としては特に不足なさそうです。

想定読者

  • Deno + Fresh でお問い合わせフォーム的なことをしてみたい人
    • Fresh については詳しく解説しないので、軽くリファレンスをさらっておくと良いです。
  • TypeScript が多少分かる人
    • JavaScript だけでも大丈夫(特に難しいことはしていないので)
  • Linux の中を CLI でそれなりに泳げる人
    • 知らないコマンドをググれるなら大丈夫です

環境

  • Ubuntu 22.04
    • EOL でなければ、何を採用しても問題ないです。(ただし、アーキが arm の場合、後述の事情をクリアすること)
    • ロードバランシングや CDN などを使用していないシンプルな構成を想定します。
      • というのも、会社 HP の特性上、継続して大量のアクセスがあるものではないため、冗長構成や負荷軽減については考慮の外に置いています。
      • (対策が必要なほどバズることがあれば、それは会社としては嬉しい話なのですが...)
  • Apache2
    • 弊社では nginx ではなく Apache2 を採用しています
  • Deno
    • その時点の最新版が入っていると良いです
  • Fresh
    • 開発当時は、V 1.1.2 でした。

[opt.] Arm Linux環境の場合について

2023.01 時点、Deno 公式 曰く。

On macOS, both M1 (arm64) and Intel (x64) executables are provided. On Linux and Windows, only x64 is supported.

とのことです。

ただ、有志によって、Deno for ARM64 というカタチでビルドしてくださっている方がいます。
これを採用するか、自前でビルドするか、そもそもアーキを変えるか、最適な判断してください。

作業

お問い合わせフォーム全体のフローとしては

  1. HTML のフォームから、ツールに向けて POST する。
  2. バリデーションし、その結果を結果画面として HTML で返却する。
  3. (結果が OK なら)送信ボタンから、ツールに向けて POST してもらい、弊社担当宛てに送信処理をかける。
  4. 送信完了画面を表示する。

の 4 工程ありますが、今回自作する機能で、2~4の部分を担います。
(POST-MAIL が2~4を担っていたため、このあたりは完全な置き換えを目指します。)

1. Deno + Fresh のプロジェクトを用意する

Deno はインストールされていることを前提に進めます。

create a project に書いてある通りに、適切なディレクトリ上で初期プロジェクトを落としてください。

mkdir -p /var/deno
cd /var/deno
deno run -A -r https://fresh.deno.dev mail_form
cd mail_form
deno task start

これで、初期プロジェクトが展開され、 Deno が実行されます。
まず、これが正常に動くことを確認したうえで、開発を進めてください。

2. お問い合わせフォームから振ってくるデータを Fresh 側でコントロールできるようにする

今回は、お問い合わせフォームの form タグの action の向き先を Fresh に向けます。
(ちなみに、時間と労力さえ有れば、お問い合わせフォームそのものを Fresh に取り込んで稼働もできますが、さすがにオーバースペックなので止めました。)

HTMLのフォーム内容について

お問い合わせフォームの HTML を読むと、このような form タグで構成されていました。

<form action="postmail/postmail.cgi" method="post">
	<input type="hidden" name="need" value="name email" />
	<div>
		<div>
			<label for="name">お名前<span>*</span></label>
			<input type="text" id="name" name="name" value="" placeholder="">
		</div>

		<div>
			<label for="email">Eメール<span>*</span></label>
			<input type="email" id="email" name="email" value="" placeholder="">
		</div>

		<div>
			<label for="email">メッセージ<span>*</span></label>
			<input type="email" id="message" name="message" value="" placeholder="">
		</div>
	</div>

	<button type="submit">送信</button>
</form>

この場合、「need」と「name」と「email」がフォームデータに載って POST されます。
ちなみに、「need」という変数に格納する値で必須属性を管理しているようです。

お問い合わせフォームは、用途によって同じようなものが複数設置されることもあります。
また、バリデーションツールとしてうまく使うには、HTML から飛んでくるフォームの内容に対して柔軟な対応ができる仕組みを求められます。

そのため今回は、「need」を除く自由な変数名を受け付け、受けたフォームデータの中身を全てひも解いてオブジェクトに格納します。

Fresh の実装

まず、action の向き先となる名前を「formpreview」と定義し、Fresh 上で作成します。
ついでに、後述する機能のために「send/[check_id].tsx」というディレクトリ+ファイルを作成します。

mailforn
├── routes
│   ├── send
│   │   └── [check_id].tsx
│   └── formpreview.tsx
..(中略)

routes ディレクトリの中に、同一の名前のファイルを作成するだけで機能します。簡単。

また、[check_id].tsx という名前は、仮置きではなく正式なファイル名です。原文ママで作成してください。
これは、Fresh の仕様で、ディレクトリを切ると「/send/」というパス扱いとなり、「[変数名]」とするとそのまま変数として認識されます。

以下のサンプルは、TypeScript のお作法としてはあまりよろしくない処理で書いたゴリ押しコードです。
ここは、より良い方法に置き替えてください。

routes/formpreview.tsx(抜粋)
class PostFormInputs {
  // -- need だけは確定で存在
  private readonly need: ReadonlyArray<string>;
  data: Object & { name: string; email: string; phone: string };

  private readonly hostname: string;
  private readonly port: number;

  constructor(input: { need: string } & unknown, hostname: string, port: number) {
    const { need, ...data } = input;
    this.need = (need ?? '').split(' ');
    this.data = data as unknown & { name: string; email: string; phone: string };
    for (let n of this.need.values()) {
      if ((this.data as any)[n] === undefined) {
        (this.data as any)[n] = '';
      }
    }
    //
    this.hostname = hostname;
    this.port = port;
  }
}

interface CtxPageProps {
  postFormInputs?: PostFormInputs;
  simpleErrorMsg: string | undefined;
  errorMsg?: string;
}

export const handler: Handlers = {
  async POST(req, ctx) {
    // --
    const remoteAddr = ctx.remoteAddr as Deno.NetAddr;
    const { hostname, port } = remoteAddr;
    //
    const formdata = await req.formData(); 
    let obj: any = {}; 
    for (let pair of formdata.entries()) {
      const k = `${pair[0]}`;
      const v = pair[1].toString();
      if (obj[k] === undefined) {
        obj[k] = v;
      } else {
        obj[k] += ' ' + v;
      }
    }
    return ctx.render({ postFormInputs: new PostFormInputs(obj, hostname, port), simpleErrorMsg: undefined });
  },
  GET(req, ctx) {
    return ctx.render({ simpleErrorMsg: 'GET not allowed', errorMsg: '許可された操作ではありません.\nNot an authorized operation.' });
  },
};

data の型定義で「name: string; email: string; phone: string」としていますが、ここは弊社の複数のフォームのどれからも確実に降ってくる腹積もりでいる変数名を明示的にしています。
このあたりは自由にやってください。

ひも解いたデータを基に、バリデーション処理と HTML 構築をします。
以下のサンプルコードでは、UI(CSS)の大部分を省略していますので、必要に応じて追記してください。
機能としては、バリデーションエラー時にはどういったエラーなのかユーザーに指摘しても良いと思います。

formpreview.tsx(抜粋)
interface FormPreviewItem {
  prop_name: string;
  prop_value: string;
  is_valid: boolean;
  error_msg?: string;
}

export default function formpreview({ data }: PageProps<CtxPageProps>) {
  const { postFormInputs, simpleErrorMsg, errorMsg } = data;
  // -- 基礎的なバリデーション.
  if (simpleErrorMsg !== undefined) {
    return <ErrorMessages {...{ simpleErrorMsg, errorMsg }} />;
  } else if (postFormInputs === undefined) {
    return <ErrorMessages simpleErrorMsg='formdata is not found.' />;
  }
  // --
  const previewArray: Array<FormPreviewItem> = buildItemArray(postFormInputs); // <-- HTMLに流せる形式に読みかえる+自作バリデーション処理を関数で切り出し.
  const hasError = previewArray.map((it) => it.is_valid).includes(false);
  // --
  return (
    <>
      <Head>
        <title>フォーム内容確認</title>
      </Head>

      <div >
        <form action={hasError ? '' : `send/${createSendingId() /* 後述 */}`} method={hasError ? '' : `post`} >
          {hasError ? null : previewArray.map((item) => {
            return <input name={item.prop_name} value={item.prop_value}></input>;
          })}
            <button>送信</button>
          </div>
        </form>
      </div>
    </>
  );
}

buildItemArray() は自作関数で、下段の HTML 吐き出しで map で回しやすくするために変換をかけつつ、今回目的としたバリデーション処理を一緒に行います。

createSendingId() も自作関数で、UUID 生成したランダムな文字列を与えます(また、生成した文字列は、Map<string,date>で一意管理します)。
こちらは、ユーザーにフォームの内容確認をさせた後「送信」を押した時に投げてもらう URL をユニークとすることで、レビューを通過せず好き勝手お問い合わせできないようにしています。

buildItemArray() の中身

前述の通り、変換処理+バリデーション判定の自作関数です。

以下は、機能のサンプルです。実際にはもうちょっと複雑なことをしています。
ここでは、「need」で受け付けた名前の判定+特定の名前に対する専用バリデーションを行っています。

バリデーションツールを自作する強みは、このあたりの仕様をある程度決め打ちで書けるところにあります。
そのため、容赦なく固定の変数名に対して処理を書いていきましょう。

export const buildItemArray = (src:PostFormInputs): Array<FormPreviewItem> => {
    const isNotBlankKeySet = new Set<string>(src.need);
    let res: Array<FormPreviewItem> = [];
    // --
    const d = src.data;
    Object.keys(d).forEach(function (key) {
      const newItem = check(
        key,
        (d as any)[key] ?? '',
        isNotBlankKeySet,
      );
      res = [...res, newItem];
    });

    return res;
}

const check = (
  prop_name: string,
  prop_value: string,
  isNotBlankKeySet: Set<string>,
): FormPreviewItem => {
  let error_msg = undefined;
  // --
  const isNotBlank = isNotBlankKeySet.has(prop_name) ? prop_value !== '' : true;
  if (!isNotBlank && error_msg === undefined) {
    error_msg = '空欄は無効です';
  }

  // -- Email
  const isValidEmail = prop_name === 'email' ? isEmail(prop_value).valid : true;
  if (!isValidEmail && error_msg === undefined) {
    error_msg = 'メールの書式が不正です';
  }

  // -- Message
  const ngword = prop_name === 'message' ? checkNgWord(prop_value) : undefined;
  const isValidNgWord = ngword === undefined;
  if (!isValidNgWord && error_msg === undefined) {
    error_msg = `次の禁止ワードが入力されています -> ${ngword}`;
  }

  const is_valid = isNotBlank && isValidEmail && isValidNgWord;
  return { is_valid, prop_name, prop_value, error_msg };
};

isEmail() は、メールアドレスの型式チェックです。
checkNgWord() は、スパム対策の自作辞書とのマッチングをしています。

3. 確認画面上の送信ボタンの挙動

順当に開発すると、確認画面の表示とボタンの発火のところまで進めることができたでしょう。
今回は、Slack にお問い合わせを送付することを想定して、送信ボタン発火時の挙動を実装します。

Slack 用の Webhook URL については、私が書いた以下の記事を参考にしてください。

以下、サンプルコードです。

routes/send/\[check_id].tsx
interface CtxPageProps {
  simpleErrorMsg: string | undefined;
  errorMsg?: string;
}

export const handler: Handlers = {
  async POST(req, ctx) {
    // --
    const remoteAddr = ctx.remoteAddr as Deno.NetAddr;
    const { hostname, port } = remoteAddr;
    let remoteAddress = hostname;
    const xForwardedFor: Array<string> = req.headers.get('X-FORWARDED-FOR')?.split(',') ?? [];
    if (1 < xForwardedFor.length) {
      remoteAddress = xForwardedFor[0];
    }
    const { check_id } = ctx.params;
    // --
    const formdata = await req.formData();
    let obj: any = {};
    for (let pair of formdata.entries()) {
      const k = `${pair[0]}`;
      const v = pair[1].toString();
      obj[k] = v;
    }
    const postFormInputs = new PostFormInputs(obj, remoteAddress, port);
    // IDチェック
    const checkIdResult = checkSendingId(check_id);
    if (checkIdResult === ERROR_TYPE.RECORD_NOT_FOUND) {
      const errorMsg = `送信処理の重複、または送信IDの不正操作を検知しました。`;
      return ctx.render({ simpleErrorMsg: 'Duplicate transmission detected.', errorMsg });
    } else if (checkIdResult === ERROR_TYPE.TIMEOUT) {
      const errorMsg = `送信のタイムアウトが発生しました。プレビューをしばらく放置した場合、そのセッションは無効となります。`;
      return ctx.render({ simpleErrorMsg: 'Duplicate transmission detected.', errorMsg });
    }
    // --
    sendToSlack(postFormInputs);
    // --
    removeId(check_id);
    return ctx.render({ simpleErrorMsg: undefined });
  },
  GET(req, ctx) {
    return ctx.render({ simpleErrorMsg: 'GET not allowed', errorMsg: '許可された操作ではありません.\nNot an authorized operation.' });
  },
};

export default function processEndView({ data }: PageProps<CtxPageProps>) {
  const { simpleErrorMsg, errorMsg } = data;
  // -- validation.
  if (simpleErrorMsg !== undefined) {
    return <ErrorMessages {...{ simpleErrorMsg, errorMsg }} />;
  }

  // --
  return <SendOk />;
}

自作関数でラップしている箇所はいくつかありますが、実装としてはシンプルです。

  1. X-FORWARDED-FOR 対策をしつつ、送信者 IP を取得する。
  2. URL 上の check_id 変数が直近生成された ID か判定する。
  3. 問題なければ、sendToSlack() という自作関数を用いて、Slack に送信する(中味は fetch() とかそういうシンプルなものです)。

Slack へ送信する前に、再度バリデーション判定をしたり、IP アドレスを見て悪質なものを弾いたりしても良いでしょう。

もし、Slack ではなくメール送信をしたい場合は、別途メールクライアントライブラリを使用することになるかと思います。
ただ、Deno Deploy などではメールクライアント系が規約上動かせない事情もあり、ライブラリ開発があまり活発でありません。それなりに試行錯誤が必要です。

同一マシン上に Postfix などのメールサーバーシステムを内包していれば、サブプロセスで sendmail に繋ぐなどもできるかもしれません。

4. Apache2 に繋ぐ

ここまでで、ポートについて特に何も設定していなければ、おそらく http://localhost:8000 で Fresh が稼働します。
もちろん、このままでは外部からのリクエストを受けることはできませんし、8000 をそのまま外に向けて開放するのもあまりよろしくないです。

ここで、apache2 側で、「formpreview」や「send/[check_id]」のパスを http/https で受けたとき、Fresh に流すように設定します。
今回は、http のみの設定を想定して記述します。

まず、Fresh を起動するポートも明示的に指定しておきます。

main.ts
await start(manifest, { 
  port: 8081, 
  plugins: [twindPlugin(twindConfig)]
 });

ここでは、8081 としました。

次に、apache2 側の conf ファイルに次のようなルールを追記します。
もしバーチャルホストを有効にしている場合は、記述可能な領域がターゲットとなる領域の中と決まっているので注意してください。

proxy_http.conf
  ProxyPass /_frsh/ http://localhost:8081/_frsh/
  ProxyPass /formpreview http://localhost:8081/formpreview
  ProxyPass /send/ http://localhost:8081/send/

「_frsh」は、Fresh が必要に応じて読みに行くデータが置かれる領域です。そのため通せるようにしておきます。
他2つは、今回の開発で作ったパスなので、忘れず通せるようにします。

apache2 のデフォルト状態によっては、proxy_http が無効になっているケースがあるため、有効化し、設定もリロードします。

sudo a2enmod proxy
sudo a2enmod proxy_http
sudo systemctl reload apache2

あとは、お問い合わせフォームの HTML の action を「formpreview」と置き替えて完成です。

5. デーモンを作成して登録する

あとは、Fresh を常駐させるだけです。

場合によってはマシンを再起動することもあるため、デーモンなどで自動起動できるようにしていないと、お問い合わせフォームが止まってしまいます。
気づかないと単純な機会損失につながってしまうため、非常にまずいです。

ということで、自動で Fresh が稼働できるよう、デーモンを作成して登録します。

/etc/systemd/system/fresh_mail_form.service
[Unit]
Description = Deno Fresh Mail Form Daemon 
After=local-fs.target
ConditionPathExists=/var/deno/mail_form

[Service]
ExecStart=/var/deno/mail_form/prod_run.sh
Restart=no
Type=simple

[Install]
WantedBy=multi-user.target

デーモンを走らせるためには、以下のようなシェルスクリプトを経由しておく方が無難です。
私の環境では、 deno.lock を消さないと正しく再実行されないので消す処理も入れてあります。

/var/deno/mail_form/prod_run.sh
#!/bin/sh

cd /var/deno/mail_form
rm deno.lock
deno task start 

sudo systemctl daemon-reload
sudo systemctl status fresh_mail_form
sudo systemctl enable fresh_mail_form
sudo systemctl start fresh_mail_form
sudo systemctl status fresh_mail_form

これで、マシンが再起動しても自動的に復活してくれます。

6. スパム対策のためにお問い合わせフォームのNGワードを定義する。

前述の「checkNgWord()」のような処理の中で、自作の NG ワードを弾くようにしましょう。

あまり厳密にしすぎると本来受け付けたい問い合わせも弾きうるため、それはそれで機会損失となりますが、スパム対策としては多少は定義しておきたいところです。
実際にはもっと多くのワードを禁止にしていますが、内容を公開するとスパム対策にならないため、今回は、一般的に効果のある以下のワードを、NG にします。

http://
https://
メールの配信停止

経験則として、本文に URL を貼ってくる 6 割くらいはスパム業者です。加えて、メルマガを転記したような文章が混ざっているものは問答無用でスパムです。
そのあたりは、実際に届いてくるものを見つつ、調整するのが良いでしょう。

ちなみに私は、.ts ファイルに配列で辞書を定義して、何かあれば直接書き足し、都度 systemctl restart しています。
前述の通り Deno はビルドなしで実行されるため、実質的なダウンタイムは、長くて 1 秒程度です。気兼ねなくリスタートをかけることができます。
(リビルドする必要がないので、JSON とかにデータを置くようなことを考えなくて楽です。)

稼働

すでに、弊社のお問い合わせフォームでは自作のバリデーションツールが稼働しています。

改善されたこと

  • 明らかにスパム業者からの問い合わせが減りました。
    • 特に、機械的に「巡回して見つけたフォームに値を入れて、送信」みたいなやつは、送信処理までたどり着いていないようです。幸せ。
  • 怪しい URL が貼られることも減りました。
    • 時々、かなり怪しい URL を貼ってくるケースがあったため、こちらも弾けています。幸せ。
  • (副次的な改善ですが)今まで改行がなぜか「<br />」と置き換わる仕様になっていて、届く内容が非常に読みづらかったのですが、正しい改行で届くようになりました。
    • おそらく以前のマシンをセットアップした時に、そうせざるを得ない事情があったのかもしれません。

さらに改善が必要なこと

  • バリデーションルールを手で直して突破してくるスパム業者がいました。
    • 「https」を「https」にしたり「http S」にしたり、試行錯誤の跡が見られます。微笑ましいですね。
    • ここはいたちごっこなので、どこかで線引きが必要です。↑の置き換えでは、誤クリックも起きないのでこのあたりが妥当とは思います。
  • Deno がメモリを多少食うので、チューニングが必要。
    • 今回の内容はメモリをガツガツ食うよう処理はないはずないので、 V8 flags あたりを与えて、明示的にメモリ制限をかけても良いかなと思っています。(たしかできたはず)
    • デーモンの設定で、メモリ制限や CPU 制限ができるので、そちらでも良いかも。
  • バリデーション NG だった内容のロギング
    • バリデーション判定で NG だった時をトリガーにロギングしても良いかも?
  • UI の改善
    • 即席で起こしたので、だいぶ改善の余地があります。
  • その他。仕様バグを突かれるなど、悪意ある操作への対策。
    • この記事で処理の一部を開示していることもあり、本当に悪意ある方によって穴を突かれて変なお問い合わせが来るかもしれません。
    • 一応、お問い合わせフォームから被害が波及しないよう、そこだけは細心の注意を払っていますので、こちらも実際に事が起きてから検討します。
      • 性善説、性悪説、どっちベースで考えるかの差です。

所感

今まで運用していた状況と比べると、利便性の高い環境になりました。
話題になっているフレームワークを利用して何か開発すると、最先端を歩いている気になってとても気分が良いものです。

今回の Deno + Fresh の使い方では、まだまだ機能の全貌を使いこなしているわけではないですが、「こういった使い方もできます!」という事例となれば幸いです。
次の機会があれば、もうちょっと踏み込んだものを作れると良いなと思っています。(たとえば、ウェブサービスのフロントエンドとして稼働させたり!)

最後に念を押しますが、POST-MAIL は非常に素晴らしいプロダクトです。
旧サーバーをセットアップした当時の選択として最適解だったと思われますし、現在進行形で更新され、かつ支持されているプロダクトということは、本当にすごいです。
ただ、今回は単純に、マイグレーションに頓挫した私のキャッチアップ不足です。日々精進あるのみです。

おわりに

弊社では、プロジェクト運営にも活用できるドキュメント開発システム「crossnote」や、定期レポート作成の自動化にも活用できるクラウドサービス「EDITROOM」といったサービスを、B2B 向けに展開しています。
特に、「ドキュメント開発」と呼称されるような、共同作業によって1つの文書を作成・レビューするような環境を提供することに関しては、実績と信頼があると自負しております。

これらプロダクトにご興味がありましたら、今回 Deno+Fresh で置き替えたバリデーションツールも使用している 弊社お問い合わせ窓口まで、お気軽にお問い合わせください。
トライアル環境を用意するなど、すぐにお試しいただけるよう対応させていただきます。

Discussion