Next.js(SSG)とPHPで作るお問い合わせフォーム:さくらレンタルサーバ

はじめに

ちょうどNext.jsを触り出した頃、友人から事業用のホームページ制作を依頼されました。
現在注目を集めているNext.jsを使って制作すれば、学習にもつながり非常に魅力的だと感じましたが、予算が限られていたため、できるだけコストを抑えて制作する必要がありました。
Next.jsを使用する場合、可能な限りサーバーサイドレンダリング(SSR)を活用したいところですが、SSRを実現するにはVPSなどのNode.jsを動作させる環境が必要となります。
しかし、コスト面を考慮すると、レンタルサーバー上で静的サイト生成(SSG)を用いる方法が適していると判断しました。

さらに、お問い合わせフォームも無料で利用できるサービスが見当たらなかったため、以下の記事を参考に、レンタルサーバー上で動作するPHPを用いて一からお問い合わせフォームを構築することにしました。

Next.js + React Hook Form + バニラPHPでお問い合わせメールを送る

上記の記事を、より実践的な形に落とし込んだのが当記事です。参考にさせていただいた記事に敬意を表します🙏

必要なもの

  1. Next.jsプロジェクト: 既にセットアップ済み、もしくは新規に作成します。
  2. PHPをローカルで動かせる環境:今回は簡単なのでMAMPを使います。Docker等でもいいですね。さくらのレンタルサーバとバージョンをあわせてください。
  3. さくらレンタルサーバのアカウント
    • ドメイン名
      下の画像で確認してください。SPF、DKIM、DMARCの設定をしてください。
    • メールアドレス
      「メール」→「メール一覧」→「新規作成」で作成します。その際にパスワード等は忘れないでください。
    • SMTPサーバー情報
      ・SMTPサーバー:〜.sakura.ne.jp(初期ドメイン)
      ・SMTPユーザー:上記設定メールアドレス@ドメイン名
      ・SMTPパスワード:メール新規作成で作ったパスワード
      ・ポート:587

環境

  • Next.js 14
  • React 18
  • React Hook Form 7
  • axios 1.7
  • PHP 8.3
  • Composer 2.7
  • PHPMailer 6.9
  • Dotenv 5.6

    さくらレンタルサーバ側
  • FreeBSD 13
  • PHP 8.3
  • Composer 2.7
  • Apache 2.4

フロントエンドの実装(Next.js)

Next.jsを使用してお問い合わせフォームを作成します。フォームにはreact-hook-formを使用し、入力バリデーションや送信処理を簡潔に行います。
今回のプロジェクトでは、以下のようなフォルダ構成を採用しました。

フォルダ構成

.env.development
.env.production
src/
└── app/
    ├── components/
    │   ├── Modal.tsx
    │   └── Contact.tsx
    ├── complete/
    │   └── page.tsx
    ├── error/
    │   └── page.tsx
    └── page.tsx

必要最低限の構成となっております。プロジェクトでは各々のルールで作成してください🙇‍♀️

必要なパッケージのインストール

まず、必要なパッケージをインストールします。

npm install react-hook-form axios
  • react-hook-form: フォームの管理とバリデーションを行います。
  • axios: フォームデータをバックエンドに送信するために使用します。

コンポーネントの作成

以下は、Contact.tsxに記述するお問い合わせフォームのコンポーネントです。

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm, SubmitHandler } from "react-hook-form";
import axios from "axios";
import Modal from "./Modal";

type Inputs = {
  name: string;
  email: string;
  message: string;
  submit: any;
};

export default function Contact() {
  const router = useRouter();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<Inputs>();

  const [isModalOpen, setIsModalOpen] = useState(false);
  const [formData, setFormData] = useState<Inputs | null>(null);

  const handleConfirmSubmit = (data: Inputs) => {
    setFormData(data);
    setIsModalOpen(true);
  };

  const onSubmit: SubmitHandler<Inputs> = async () => {
    if (!formData) return;

    try {
      await axios.post(
        process.env.NEXT_PUBLIC_FETCH_URL!,
        JSON.stringify(formData),
        {
          headers: {
            "Content-Type": "application/json",
          },
        }
      );
      router.push("/contact/complete");
    } catch (error) {
      router.push("/contact/error");
    } finally {
      setIsModalOpen(false);
    }
  };

  return (
    <div className="container">
      <div className="inquiry">
        <form id="mailForm" onSubmit={handleSubmit(handleConfirmSubmit)}>
          <div className="column2">
            <label htmlFor="name">
              <span className="required">必須</span>
              お名前
            </label>
            <div className="inputWrap">
              <input
                type="text"
                id="name"
                placeholder="お名前"
                {...register("name", {
                  required: "お名前を入力してください。",
                  maxLength: {
                    value: 20,
                    message: "20文字以下で入力してください。",
                  },
                })}
              />
              {errors.name?.message && (
                <p className="errorMessage">{errors.name?.message}</p>
              )}
            </div>
          </div>
          <div className="column2">
            <label htmlFor="email">
              <span className="required">必須</span>
              メール
            </label>
            <div className="inputWrap">
              <input
                type="email"
                id="email"
                placeholder="メールアドレス"
                {...register("email", {
                  required: "メールアドレスを入力してください。",
                  maxLength: {
                    value: 50,
                    message: "50文字以下で入力してください。",
                  },
                  pattern: {
                    value:
                      /^[a-zA-Z0-9_.+-]+@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/,
                    message: "正しいメールアドレスを入力してください。",
                  },
                })}
              />
              {errors.email?.message && (
                <p className="errorMessage">{errors.email?.message}</p>
              )}
            </div>
          </div>
          <div className="column2">
            <label htmlFor="message">
              <span className="required">必須</span>
              メッセージ
            </label>
            <div className="inputWrap">
              <textarea
                id="message"
                rows={10}
                placeholder="お問い合わせ内容"
                {...register("message", {
                  required: "メッセージを入力してください。",
                  maxLength: {
                    value: 1000,
                    message: "1000文字以下で入力してください。",
                  },
                })}
              ></textarea>
              {errors.message?.message && (
                <p className="errorMessage">{errors.message.message}</p>
              )}
            </div>
          </div>
          <div className="column2">
            <input
              type="submit"
              value="内容を確認する"
              id="sendBtn"
              name="sendBtn"
            />
          </div>
          <Modal
            isOpen={isModalOpen}
            onClose={() => setIsModalOpen(false)}
            onConfirm={handleSubmit(onSubmit)}
            formData={formData}
            isLoading={isSubmitting}
          />
        </form>
      </div>
      <style jsx>{`
        .container {
          display: flex;
          flex-direction: column;
          justify-content: center;
          align-items: center;
          min-height: 100vh;
          padding: 20px;
          margin: 0 auto;
        }

        .required {
          color: red;
        }

        .inputWrap {
          margin-top: 8px;
        }

        .errorMessage {
          color: red;
          font-size: 12px;
        }

        input[type="text"],
        input[type="email"],
        input[type="phone"],
        textarea {
          width: 100%;
          font-size: 1rem;
          padding: 0.5rem 1rem;
          border-radius: 0.5rem;
          border: none;
          box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
          background-color: #f6eddb;
          box-sizing: border-box;
          flex: 2;
        }

        input[type="submit"] {
          display: inline-block;
          padding: 10px 20px;
          margin: 0 auto;
          font-size: 16px;
          font-weight: bold;
          color: #ffffff;
          background-color: #e9a320;
          border: none;
          border-radius: 4px;
          box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
          cursor: pointer;
          transition: all 0.2s ease-in-out;
          width: 50%;
          box-sizing: border-box;
        }

        input[type="submit"]:hover {
          background-color: #a33400;
          box-shadow: 0px 8px 8px rgba(0, 0, 0, 0.25);
        }

        .column2 {
          display: flex;
          justify-content: center;
          margin-bottom: 16px;
        }
        .inquiry {
          background-color: #fff;
          border-radius: 20px;
          box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
          padding: 20px;
          width: 700px;
          box-sizing: border-box;
          margin: 0 auto;
        }

        .inquiry .inner {
          max-width: 700px;
          margin: 0 auto;
        }

        .inquiry label {
          display: flex;
          flex: 0.5;
          align-items: center;
        }

        .inputWrap {
          flex: 2;
          margin-bottom: 15px;
        }

        .required {
          display: inline-block;
          padding: 2px 8px;
          background-color: red;
          color: #fff;
          border-radius: 8px;
          margin-right: 4px;
          font-size: 12px;
        }
      `}</style>
    </div>
  );
}

以下は、Modal.tsxに記述するお問い合わせフォームのモーダルコンポーネントです。

import { useEffect } from "react";

type ModalProps = {
  isOpen: boolean;
  onClose: () => void;
  onConfirm: () => void;
  formData: {
    name: string;
    email: string;
    message: string;
  } | null;
  isLoading: boolean;
};

const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  onConfirm,
  formData,
  isLoading,
}) => {
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = "hidden";
    } else {
      document.body.style.overflow = "";
    }

    return () => {
      document.body.style.overflow = "";
    };
  }, [isOpen]);

  if (!isOpen || !formData) return null;

  return (
    <div className="modalBackdrop">
      <div className="modal">
        <h2 className="modalTitle">この内容で送信します</h2>
        <div className="inquiry">
          <div className="column2">
            <label>お名前</label>
            <div className="inputWrap">
              <input type="text" id="name" value={formData.name} disabled />
            </div>
          </div>
          <div className="column2">
            <label>メール</label>
            <div className="inputWrap">
              <input type="email" id="email" value={formData.email} disabled />
            </div>
          </div>
          <div className="column2">
            <label>メッセージ</label>
            <div className="inputWrap">
              <textarea
                id="message"
                rows={5}
                value={formData.message}
                disabled
              ></textarea>
            </div>
          </div>
          <div className="modalActions">
            <button
              className="modalButton"
              onClick={onConfirm}
              disabled={isLoading}
            >
              {isLoading ? "送信中..." : "送信する"}
            </button>
            <button
              className="modalButton cancel"
              onClick={onClose}
              disabled={isLoading}
            >
              キャンセル
            </button>
          </div>
        </div>
      </div>
      <style jsx>{`
        .modalBackdrop {
          position: fixed;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          background-color: rgba(0, 0, 0, 0.5);
          display: flex;
          justify-content: center;
          align-items: center;
          z-index: 1000;
        }

        .modal {
          position: fixed;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          background-color: white;
          padding: 20px;
          border-radius: 10px;
          width: 700px;
          box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
          z-index: 1001;
        }

        .modalTitle {
          font-size: 20px;
          margin-bottom: 16px;
          text-align: center;
        }

        .modalActions {
          display: flex;
          margin-top: 20px;
        }

        .modalButton {
          display: inline-block;
          padding: 10px 20px;
          margin: 0 5px 20px 5px;
          font-size: 16px;
          font-weight: bold;
          color: #ffffff;
          background-color: #e9a320;
          border: none;
          border-radius: 4px;
          box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
          cursor: pointer;
          transition: all 0.2s ease-in-out;
          width: 100%;
          box-sizing: border-box;
        }

        .modalButton:disabled {
          background-color: #ccc;
          cursor: not-allowed;
          box-shadow: none;
          opacity: 0.6;
          pointer-events: none;
          transition: none;
        }

        .column2 {
          display: flex;
          justify-content: center;
          margin-bottom: 16px;
        }

        .inquiry label {
          display: block;
          flex: 0.5;
        }

        .inquiry input[type="text"],
        .inquiry input[type="email"],
        .inquiry textarea {
          width: 100%;
          font-size: 1rem;
          padding: 0.5rem 1rem;
          border-radius: 0.5rem;
          border: none;
          box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
          box-sizing: border-box;
          color: gray;
          background-color: rgb(233, 233, 233);
          flex: 2;
        }

        .inputWrap {
          flex: 2;
          margin-bottom: 15px;
        }

        .modalButton:hover {
          background-color: #a33400;
          box-shadow: 0px 8px 8px rgba(0, 0, 0, 0.25);
        }

        .modalButton.cancel {
          background-color: #e74c3c;
          color: white;
        }

        .modalButton.cancel:hover {
          background-color: #a33400;
          box-shadow: 0px 8px 8px rgba(0, 0, 0, 0.25);
        }
      `}</style>
    </div>
  );
};

export default Modal;

送信完了後に表示されるcomplete/page.tsx、エラー時のerror/page.tsxは適当に作っておきます。

export default function CompletePage(){
  return (
  <>
    送信完了しました
  </>
  )
}
export default function ErrorPage() {
  return (
    <>
      エラーが発生しました
    </>
  );
}

コード解説

Contact.tsx

インポートと依存関係

  • Reactおよびフック
    • useState: ローカル状態(モーダルの開閉状態やフォームデータ)を管理。
  • react-hook-form
    • フォームの状態管理とバリデーションを効率的に行うためのライブラリ。
  • axios
    • HTTPリクエストを行うためのライブラリ。ここではフォームデータの送信に使用。
  • next/navigation
    • Next.jsのルーターを使用してページ遷移を制御。
  • Modalコンポーネント
    • 確認モーダルを表示するためにカスタムコンポーネントをインポート。

型定義

type Inputs = {
  name: string;
  email: string;
  message: string;
  submit: any;
};

フォームで扱う入力データの型を定義しています。

フォーム管理

const {
  register,
  handleSubmit,
  formState: { errors, isSubmitting },
} = useForm<Inputs>();
  • useFormフックを使用してフォームの状態を管理。registerでフォームフィールドを登録し、handleSubmitで送信時の処理をラップします。
  • errorsにはバリデーションエラーが格納され、isSubmittingはフォーム送信中の状態を示します。

ローカル状態の管理

const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<Inputs | null>(null);
  • isModalOpen: モーダルの開閉状態を管理。
  • formData: ユーザーが入力したフォームデータを一時的に保存。

送信前の確認処理

const handleConfirmSubmit = (data: Inputs) => {
  setFormData(data);
  setIsModalOpen(true);
};

ユーザーがフォームを送信しようとした際に呼び出され、入力データをformDataに保存し、確認モーダルを開きます。

最終送信処理

const onSubmit: SubmitHandler<Inputs> = async () => {
  if (!formData) return;

  try {
    await axios.post(
      process.env.NEXT_PUBLIC_FETCH_URL!,
      JSON.stringify(formData),
      {
        headers: {
          "Content-Type": "application/json",
        },
      }
    );
    router.push("/contact/complete");
  } catch (error) {
    router.push("/contact/error");
  } finally {
    setIsModalOpen(false);
  }
};
  • onSubmit関数は、実際にデータをサーバーに送信する処理を担当。
  • 環境変数
    • process.env.NEXT_PUBLIC_FETCH_URL: データ送信先のURLを環境変数として設定。NEXT_PUBLIC_で始まる環境変数はクライアントサイドでもアクセス可能。
  • エラーハンドリング
    • データ送信が成功した場合は/contact/completeページへ遷移。
    • 失敗した場合は/contact/errorページへ遷移。
  • 最終的にモーダルを閉じます。

レンダリング部分

  • フォームフィールド(名前、メール、メッセージ)の入力欄とバリデーションエラーメッセージを表示。
  • 送信ボタンを押すとhandleConfirmSubmitが呼び出され、モーダルが表示されます。
  • Modalコンポーネントをレンダリングし、モーダルの開閉や送信確認を制御。

Modal.tsx

インポートと依存関係

  • Reactおよびフック
    • useEffect: モーダルの開閉に応じて、背景のスクロールを制御。

型定義

type ModalProps = {
  isOpen: boolean;
  onClose: () => void;
  onConfirm: () => void;
  formData: {
    name: string;
    email: string;
    message: string;
  } | null;
  isLoading: boolean;
};
  • isOpen: モーダルが開いているかどうかの状態。
  • onClose: モーダルを閉じるための関数。
  • onConfirm: 確認後に実行される送信関数。
  • formData: 表示するフォームデータ。
  • isLoading: 送信中の状態を示すフラグ。

スクロール制御

useEffect(() => {
  if (isOpen) {
    document.body.style.overflow = "hidden";
  } else {
    document.body.style.overflow = "";
  }

  return () => {
    document.body.style.overflow = "";
  };
}, [isOpen]);

モーダルが開いているときに背景のスクロールを無効化し、閉じると元に戻します。クリーンアップ関数で確実にスクロールを元に戻すようにしています。

レンダリング条件

if (!isOpen || !formData) return null;

モーダルが開いていない、またはフォームデータが存在しない場合は何もレンダリングしません。

モーダルの内容

  • フォームデータ(名前、メール、メッセージ)を表示する入力欄をdisabled状態で表示。
  • 送信ボタンとキャンセルボタンを提供。
    • 送信ボタン
      • クリックするとonConfirmが呼ばれ、最終送信処理が実行されます。
      • isLoadingtrueの場合、ボタンは無効化され「送信中…」と表示されます。
    • キャンセルボタン
      • クリックするとonCloseが呼ばれ、モーダルが閉じられます。
      • 送信中はボタンが無効化されます。

環境変数について

Contactコンポーネント内で使用されている環境変数NEXT_PUBLIC_FETCH_URLについて説明します。

process.env.NEXT_PUBLIC_FETCH_URL!
  • 役割
    • NEXT_PUBLIC_FETCH_URL は、フォームデータを送信するAPIのエンドポイントURLを指定する環境変数です。Contact コンポーネント内で使用されており、.envに記述されているURLに対してフォームデータが送信されます。この環境変数は、プロジェクトのルートにある .env ファイルで定義されますが、今回は開発環境と本番環境で異なるAPIエンドポイントを使用するため、.env.production と .env.development の2つのファイルに分けて設定します。
  • 開発環境(ローカル環境)の設定
    • ローカル環境では、MAMPを使用し、APIエンドポイントをローカルサーバーに設定します。このため、.env.development ファイルで設定を行います。
      project_nameは各自書き換えて下さい。
NEXT_PUBLIC_FETCH_URL=http://localhost:8888/project_name/api/mail
  • 本番環境の設定
    • 本番環境では、公開されているエンドポイントを指定します。このため、.env.production ファイルを作成し、以下のように設定します。

      ここでは、https://example.com/ をベースにしています。実際には、本番用のURLを指定します。
NEXT_PUBLIC_FETCH_URL=https://example.com/api/mail
  • プレフィックスの意味
    • NEXT_PUBLIC_で始まる環境変数は、クライアントサイド(ブラウザ)でもアクセス可能です。これにより、フロントエンドコードから直接参照できます。
  • セキュリティ
    • 公開される環境変数には機密情報を含めないように注意が必要です。APIキーや秘密情報はNEXT_PUBLIC_プレフィックスを付けずにサーバーサイドでのみ使用します。

これにより、以下のバックエンド実装後、ローカル開発環境でメールが送れるようになります。

バックエンドの実装

バックエンドでは、さくらレンタルサーバー上で動作するPHPスクリプトを用いて、受け取ったフォームデータをメール送信します。
PHPMailerを利用してSMTP経由でメールを送信することで、信頼性の高いメール配信を実現します。また、ローカル開発環境ではMAMPを使用します。

MAMPのファイル構成

ローカル環境での開発のMAMP内ディレクトリ構成は次のようにしました。

MAMP/
└── htdocs/
    ├── project_name/
        └── api/
            ├── .env
            └── mail/
                └── index.php

MAMPのデフォルトドキュメントルートはhtdocsディレクトリです。開発中のPHPプロジェクトをhtdocs内に配置することで、ローカルホストからアクセス可能になります。

Composerのインストール

Composer公式サイトのコードを参考にしてグローバルにインストールします。

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"
sudo mv composer.phar /usr/local/bin/composer
composer --version

正常にインストールされていれば、Composerのバージョン情報が表示されます。

プロジェクトへのComposerの設定

apiディレクトリへ移動

プロジェクトのapiディレクトリに移動します。

cd /Applications/MAMP/htdocs/project_name/api

/Applications/project_nameは実際のパスに置き換えてください。

composer.jsonの初期化

Composerの初期設定を行います。以下のコマンドを実行してcomposer.jsonを作成します。

composer init

プロンプトに従ってプロジェクト情報を入力します。必要に応じてデフォルトの設定を使用できます。

PHPMailerとdotenvのインストール

PHPMailerのインストール

以下のコマンドを実行してPHPMailerをインストールします。

composer require phpmailer/phpmailer

dotenvのインストール

以下のコマンドを実行してdotenvライブラリをインストールします。

composer require vlucas/phpdotenv

vendorディレクトリの配置確認

以下のコマンドでvendorディレクトリが正しく作成されていることを確認してください。

ls vendor

autoload.phpなどのファイルが表示されれば成功です。

phpファイルの作成

以下は、mail/index.phpです。

コード解説

依存関係の読み込みと名前空間の設定

<?php
require '/Applications/MAMP/htdocs/project_name/api/vendor/autoload.php';

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
  • require: Composerで管理されている依存関係を読み込みます。autoload.phpは、プロジェクト内のすべての依存ライブラリを自動的に読み込む役割を果たします。
  • use: PHPMailerのクラスを名前空間からインポートし、コード内で簡単に利用できるようにします。

ヘッダーの設定とCORS対応

header('Content-Type: application/json; charset=UTF-8');

// プリフライトリクエストへの対応
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
    header("Access-Control-Allow-Origin: *");
    header("Access-Control-Allow-Methods: POST, OPTIONS");
    header("Access-Control-Allow-Headers: Content-Type");
    http_response_code(200);
    exit;
}

// CORSヘッダーを設定
header("Access-Control-Allow-Origin: *");
  • Content-Type: レスポンスの内容タイプをJSONに設定します。
  • プリフライトリクエストの対応: ブラウザがCORSリクエストを送信する際、まずOPTIONSメソッドでプリフライトリクエストが行われます。これに対して、許可するオリジン、メソッド、ヘッダーを指定し、200ステータスコードで応答します。
  • Access-Control-Allow-Origin: すべてのオリジンからのリクエストを許可します。セキュリティ要件に応じて、特定のオリジンに制限することも検討してください。

CORSの設定は、必要最低限のオリジンに限定することが推奨されます。*を使用するとすべてのオリジンからのリクエストを許可するため、セキュリティリスクが高まる可能性があります。

環境変数の読み込み

$dotenv = Dotenv\Dotenv::createImmutable("/Applications/MAMP/htdocs/project_name/");
$dotenv->load();
  • Dotenv.envファイルから環境変数を読み込みます。これにより、データベースの資格情報やSMTPの設定などをコード内にハードコーディングする必要がなくなります。
  • createImmutable: 環境変数を変更不可として読み込みます。

POSTリクエストの処理

1.リファラの検証

if ($_SERVER['REQUEST_METHOD'] == 'POST') {

    $subject = "ホームページよりお問合せがありました";
    $to = 'exmaple@example.com';
    $url1 = 'http://localhost:3000';
    $url2 = 'http://exmaple.com';

    $allowedUrls = array($url1, $url2);
    $isAllowed = false;
    foreach ($allowedUrls as $allowedUrl) {
        if (strncmp($_SERVER['HTTP_REFERER'], $allowedUrl, strlen($allowedUrl)) === 0) {
            $isAllowed = true;
            break;
        }
    }

    if (!$isAllowed) {
        header("HTTP/1.1 404 Not Found");
        exit;
    }

    // 以下略...
}
  • リファラの検証: リクエストの発信元(HTTP_REFERER)が許可されたURL($allowedUrls)に含まれているかを確認します。これにより、指定されたオリジンからのリクエストのみを受け付けます。
  • 注意点: リファラヘッダーはクライアント側で簡単に偽装できるため、セキュリティ対策として完全ではありません。必要に応じて、追加の認証手段を導入してください。

リファラの検証は基本的なセキュリティ対策ですが、完全ではありません。可能であれば、トークンベースの認証やその他のセキュリティメカニズムを併用することを検討してください。

2.入力データの取得とバリデーション

$json = file_get_contents("php://input");
$contents = json_decode($json, true);
error_log(print_r($contents, true));

// バリデーションの初期化
$errors = array();

// 必須項目のチェック
if (!isset($contents["name"]) || empty(trim($contents["name"]))) {
    $errors[] = "名前が必要です。";
}

if (!isset($contents["email"]) || empty(trim($contents["email"]))) {
    $errors[] = "メールアドレスが必要です。";
} elseif (!filter_var($contents["email"], FILTER_VALIDATE_EMAIL)) {
    $errors[] = "有効なメールアドレスを入力してください。";
}

if (!isset($contents["message"]) || empty(trim($contents["message"]))) {
    $errors[] = "メッセージが必要です。";
}

if (!empty($errors)) {
    // バリデーションエラーがある場合
    http_response_code(400);
    echo json_encode([
        "status" => "validationError",
        "errors" => $errors
    ], JSON_PRETTY_PRINT);
    exit;
}
  • データの取得php://inputストリームから生のPOSTデータを取得し、JSON形式としてデコードします。
  • バリデーション:
    • 必須項目のチェックnameemailmessageの各フィールドが存在し、空でないことを確認します。
    • メールアドレスの形式チェック: PHPのfilter_var関数を用いて、メールアドレスの形式が有効かを検証します。
  • エラーハンドリング: バリデーションエラーが存在する場合、400ステータスコードとエラーメッセージをJSON形式で返します。

入力データのバリデーションは基本的なセキュリティ対策ですが、さらに詳細な検証やサニタイズを行うことで、セキュリティを強化できます。

3.データのエスケープとJSON形式での保存

// htmlspecialchars() を使用して入力データをエスケープ
$escapedName = htmlspecialchars(trim($contents["name"]), ENT_QUOTES, 'UTF-8');
$escapedEmail = htmlspecialchars(trim($contents["email"]), ENT_QUOTES, 'UTF-8');
$escapedMessage = htmlspecialchars(trim($contents["message"]), ENT_QUOTES, 'UTF-8');

// エスケープ済みデータを使用して新しい配列を作成
$safeContents = array(
    "name" => $escapedName,
    "email" => $escapedEmail,
    "message" => $escapedMessage
);

// エスケープ済みデータをJSON形式で保存
file_put_contents("contact.json", json_encode($safeContents, JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND);
  • データのエスケープhtmlspecialchars関数を使用して、入力データ内の特殊文字をエスケープし、XSS(クロスサイトスクリプティング)攻撃を防止します。

XSS対策htmlspecialcharsによるエスケープは基本的な対策ですが、出力時にも適切なエスケープ処理を行うことで、セキュリティを強化できます。
reCAPTCHAの導入: スパムや自動化された悪意のあるリクエストを防ぐために、GoogleのreCAPTCHAなどのCAPTCHAソリューションを導入することを強く推奨します。これにより、フォームのセキュリティが大幅に向上します。

  • JSON形式での保存:メール送信エラー等でメール紛失を防ぐためにJSONに保存します。
    • 保存方法: エスケープ済みデータをcontact.jsonファイルに追記します。

この方法は簡易的な実装であり、実際の運用環境ではデータベースの使用や適切なファイル管理を検討してください。JSONファイルへの直接保存は、スケーラビリティやセキュリティの面で制約があります。
ブラウザからJSONへのアクセスを禁じて下さい。以下はapi/mail/.htaccessの記述例です。

<Files "contact.json">
    Order allow,deny
    Deny from all
</Files>

4.PHPMailerを用いたメール送信

// 管理者へのメール送信処理
$mail = new PHPMailer(true);
try {
    // SMTP設定
    $mail->isSMTP();
    $mail->Host       = $_ENV['SMTP_HOST'];
    $mail->SMTPAuth   = true;
    $mail->Username   = $_ENV['SMTP_USERNAME'];
    $mail->Password   = $_ENV['SMTP_PASSWORD'];
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
    $mail->Port       = $_ENV['SMTP_PORT'];
    $mail->CharSet = 'UTF-8';

    // 送信者情報
    $mail->setFrom('exmaple@example.com', 'UserName');

    // 受信者情報
    $mail->addAddress($to);

    // メール内容
    $mail->isHTML(false);
    $mail->Subject = $subject;
    $mail->Body    = "ホームページより以下のお問い合わせがありました" . "\n" . "\n" .
                     "お名前:" . $safeContents["name"] . "\n" .
                     "メールアドレス:" . $safeContents["email"] . "\n" . "\n" .
                     $safeContents["message"] . "\n";

    // 管理者へのメール送信
    $mail->send();
    $arr["status"] = "sendOk";

    // お問い合わせした人にも確認メールを送る
    $mail->clearAddresses();
    $mail->addAddress($safeContents["email"]);
    $mail->Subject = "お問い合わせありがとうございました";
    $mail->Body    = $safeContents["name"] . " 様" . "\n" .
                     "お問い合わせいただきありがとうございました。" . "\n" .
                     "以下の内容でお問い合わせを受け付けました。" . "\n" . "\n" .
                     "お名前:" . $safeContents["name"] . "\n" .
                     "メールアドレス:" . $safeContents["email"] . "\n" . "\n" .
                     $safeContents["message"] . "\n" .
                     "追ってご連絡いたします。";

    // 送信者への確認メール送信
    $mail->send();

} catch (Exception $e) {
    error_log("Message could not be sent. Mailer Error: {$mail->ErrorInfo}");
    $arr["status"] = "sendError";
}

print json_encode($arr, JSON_PRETTY_PRINT);
  • PHPMailerの設定:
    • SMTP設定.envファイルからSMTPサーバーの設定を読み込み、SMTPを使用してメールを送信します。セキュリティを考慮し、パスワードなどの機密情報は環境変数で管理します。
    • 送信者情報: メールの送信元アドレスと名前を設定します。
    • 受信者情報: 管理者のメールアドレスにメールを送信します。
  • メール内容:
    • 管理者向けメール: お問い合わせ内容を含むメールを管理者に送信します。
    • ユーザー向け確認メール: お問い合わせを行ったユーザーに対して、確認メールを送信します。
  • エラーハンドリング: メール送信に失敗した場合、エラーログに記録し、ステータスをsendErrorとしてクライアントに返します。
  • 注意点: 同一のPHPMailerインスタンスを再利用していますが、複数のメールを送信する場合は、適切にアドレスや内容をクリアする必要があります。

メール送信のセキュリティ: SMTP認証情報は厳重に管理し、コード内にハードコーディングしないようにしましょう。
reCAPTCHAの検証後にメール送信: reCAPTCHAを導入した場合、ユーザーがCAPTCHAを正しく完了した後にのみメール送信処理を実行するようにします。

5.不正なリクエストへの対応

  • リクエストメソッドの確認: POST以外のリクエストが送信された場合、400 Bad Requestステータスコードとともにエラーメッセージを表示します。
  • ユーザー向けエラーページ: 見やすいエラーページを提供し、ユーザーに正しいリクエスト方法を案内します。

エラーメッセージはユーザーにとって分かりやすいものであると同時に、詳細な内部情報を漏らさないように注意します。

注意事項

  • JSONファイルへのデータ保存: JSONファイルへの直接保存は簡易的な実装であり、実際の運用環境ではデータベースの使用や適切なファイル管理を検討することを推奨します。JSONファイルはスケーラビリティやセキュリティの面で制約があるため、用途に応じて最適なデータストレージ方法を選択してください。
  • XSS対策の強化htmlspecialcharsによるエスケープは基本的な対策ですが、出力時にも適切なエスケープ処理を行うことや、コンテンツセキュリティポリシー(CSP)の導入など、さらなるXSS対策を講じることが重要です。
  • reCAPTCHAの導入: フォームへのスパムや自動化された攻撃を防ぐために、GoogleのreCAPTCHAなどのCAPTCHAソリューションを導入することを強く推奨します。これにより、フォームのセキュリティが大幅に向上します。
  • リファラの検証: リファラの検証は基本的なセキュリティ対策ですが、完全ではありません。可能であれば、トークンベースの認証やその他のセキュリティメカニズムを併用することで、セキュリティを強化できます。

環境変数の設定

セキュリティ向上のため、SMTPの認証情報などの機密情報は環境変数として管理します。

SMTP_HOST=******.sakura.ne.jp
SMTP_USERNAME=********@*********
SMTP_PASSWORD=*******
SMTP_PORT=587

PHPスクリプト内では、Dotenvを使用して.envファイルから環境変数を読み込みます。

メール送信テスト

Next.jsの開発ディレクトリで、npm run devを実行して開発サーバーを立ち上げます。また、MAMPを起動してサーバーを動作させます。

次に、作成したメールフォームから実際にメール送信を試みます。このとき、フォームが正しく機能していれば、送信後に complete/page.tsx が表示されるはずです。このページが表示されれば、メール送信処理が成功していることを示しています。

送信後、メールが届いていない場合は、迷惑メールフォルダも必ず確認してください。場合によっては、そちらに格納されていることもあります。

さくらのレンタルサーバへのデプロイ

準備1 : Next.jsのプロジェクトをビルド

Next.jsでプロジェクトを静的サイト生成(SSG)用にビルドするためには、まずnext.config.jsファイルを適切に設定する必要があります。
特に、重要な設定項目が output: 'export' です。この設定により、プロジェクトは静的なHTMLファイルとしてビルドされ、/out ディレクトリに書き出されます。

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export",
  trailingSlash: true,
};

export default nextConfig;

output: “export”とは?

output: "export"は、Next.jsのプロジェクトを完全な静的サイトとしてビルドするための設定です。
この設定により、サーバーサイドレンダリング(SSR)を行わずに、HTMLファイルとしてすべてのページが生成されます。
生成された静的なファイルは、ホスティングサービスやレンタルサーバーにそのままアップロードすることができます。

trailingSlash: true の意味

trailingSlash: trueを設定すると、生成されたURLの末尾にスラッシュ(/)が自動的に追加されます。
例えば、/about というページがあった場合、/about/ というURLになります。
この設定をすることで、特定のサーバー環境やSEO上の理由で推奨されるスラッシュ付きURLに統一できます。

準備2 : レンタルサーバにComposerをインストールする

SSH接続する

まず、SSHクライアント(例: ターミナル、PuTTY)を使用してさくらレンタルサーバに接続します。

ssh ユーザー名@初期ドメイン

Composerをインストールする

ユーザーのホームディレクトリに composer をインストールする手順は以下の通りです。

1.ユーザーのホームディレクトリに移動

cd ~

2.「bin」というディレクトリを作成

mkdir bin

3.Composerをインストール(ダウンロード)

curl -sS https://getcomposer.org/installer | php

4.ダウンロードしたファイルをリネームして「bin」ディレクトリに移動

mv composer.phar bin/composer

5.確認

composer -V

さくらインターネットのレンタルサーバーでは、~/bin が既に PATH に含まれているため、上記の手順で composer コマンドをどこからでも実行できるようになります。追加で PATH を設定する必要はありません。

デプロイ

Next.jsの静的ファイルをアップロード

生成された静的ファイルをレンタルサーバにアップロードします。SFTPまたはSCPを使用して、outディレクトリ内を/home/username/www/project_name/ にアップロードします。
私はGUIクライアント(Cyberduck)を用いましたが、なんでも構いません。

PHPファイルのアップロード

次に、PHPファイルをアップロードします。vendorディレクトリは含めずにアップロードを行い、後でサーバー上でComposerを使って必要な依存パッケージをインストールします。
ローカルのMAMP/htdocs/project_name/api//home/username/www/project_name/api/へアップロードします。

Composerの依存パッケージをインストール

次に、PHPプロジェクト内でcomposer installコマンドを実行し、依存パッケージをインストールします。

cd /home/username/www/project_name/api/
composer install --no-dev --optimize-autoloader

オプションの説明

  • --no-dev
    開発用パッケージをインストールしないオプションです。本番環境では不要な開発依存関係を除外し、軽量化を図ります。
  • --optimize-autoloader
    オートローダーを最適化するオプションです。パフォーマンスを向上させ、ロード時間を短縮するために使用します。特に本番環境でのパフォーマンスが重要な場合に推奨されます。

メール送信テスト

実際にホームページにアクセスして、メールを送信してみましょう。

まとめ

この記事では、Next.jsを用いたフロントエンドとPHPを用いたバックエンドで構成されたお問い合わせフォームの作成方法を解説しました。
react-hook-formによるフォーム管理、PHPMailerを用いたメール送信、環境変数の管理など、セキュアで効率的な実装手法を取り入れることで、信頼性の高いお問い合わせフォームを構築できます。
さくらレンタルサーバーへのデプロイ手順も網羅したので、実際の運用に向けてぜひ参考にしてください。