Vue.js お問い合わせフォームに CSV 保存機能を追加する方法

この記事では、Vue.js を使用したお問い合わせフォームに CSV 保存機能 を追加する方法を説明します。さらに、reCAPTCHACSRFトークン を導入し、セキュアなフォームを実現します。


プロジェクトのファイル構成

以下のような構成でプロジェクトを作成します:

project/
│
├── api/
│   ├── contact.php       // フォームデータを受け取り CSV に保存
│   ├── csrf_token.php    // CSRF トークンを発行
│   ├── recaptcha_key.php // reCAPTCHA サイトキーを提供
│   ├── config.php        // 各種設定
│
├── index.html            // フロントエンドのフォーム
├── submissions.csv       // 保存されるフォームデータ(初回実行時に自動生成)
├── .htaccess             // URL リダイレクト設定

1. index.html

index.html は Vue.js を使用してフォームの入力、確認、送信を実現します。

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue.js お問い合わせフォーム</title>
  <script>
    // API URLを設定
    const API_URL = "/api";

    // reCAPTCHA サイトキーをAPIから取得しスクリプトを動的にロード
    fetch(`${API_URL}/recaptcha_key.php`)
      .then((response) => response.json())
      .then((data) => {
        if (data.site_key) {
          const script = document.createElement('script');
          script.src = `https://www.google.com/recaptcha/api.js?render=${data.site_key}`;
          document.head.appendChild(script);
          window.recaptchaSiteKey = data.site_key; // グローバル変数に保存
        } else {
          console.error('reCAPTCHA サイトキーが取得できませんでした。');
        }
      })
      .catch(() => {
        console.error('reCAPTCHA サイトキーの取得中にエラーが発生しました。');
      });
  </script>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="https://unpkg.com/vue@3"></script>
  <script src="https://unpkg.com/vue-router@4"></script>
</head>

<body class="bg-gray-100 min-h-screen flex items-center justify-center">
  <div id="app" class="bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
    <router-view></router-view>
  </div>

  <script>
    const { createRouter, createWebHistory } = VueRouter;
    const { createApp } = Vue;

    // 入力画面
    const InputForm = {
      template: `
        <div>
          <h1 class="text-2xl font-bold mb-4">お問い合わせフォーム</h1>
          <form @submit.prevent="goToConfirm">
            <div class="mb-4">
              <label for="name" class="block text-sm font-medium text-gray-700">名前:</label>
              <input v-model="formData.name" type="text" id="name" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
            </div>
            <div class="mb-4">
              <label for="email" class="block text-sm font-medium text-gray-700">メールアドレス:</label>
              <input v-model="formData.email" type="email" id="email" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
            </div>
            <div class="mb-4">
              <label for="message" class="block text-sm font-medium text-gray-700">お問い合わせ内容:</label>
              <textarea v-model="formData.message" id="message" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"></textarea>
            </div>
            <button type="submit" class="w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">確認画面へ</button>
          </form>
        </div>
      `,
      data() {
        return {
          formData: JSON.parse(localStorage.getItem('formData')) || { name: '', email: '', message: '' },
        };
      },
      methods: {
        goToConfirm() {
          localStorage.setItem('formData', JSON.stringify(this.formData));
          this.$router.push({ name: 'confirm' });
        },
      },
    };

    // 確認画面
    const ConfirmForm = {
      template: `
        <div>
          <h1 class="text-2xl font-bold mb-4">内容の確認</h1>
          <p class="mb-2"><strong>名前:</strong> {{ formData.name }}</p>
          <p class="mb-2"><strong>メールアドレス:</strong> {{ formData.email }}</p>
          <p class="mb-4"><strong>お問い合わせ内容:</strong> {{ formData.message }}</p>
          <div class="flex space-x-4">
            <button @click="goBack" class="w-1/2 bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">戻る</button>
            <button @click="sendForm" class="w-1/2 bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">送信する</button>
          </div>
        </div>
      `,
      data() {
        return {
          formData: JSON.parse(localStorage.getItem('formData')) || { name: '', email: '', message: '' },
        };
      },
      methods: {
        goBack() {
          this.$router.push({ name: 'input' });
        },
        async sendForm() {
          try {
            const recaptchaToken = await grecaptcha.execute(window.recaptchaSiteKey, { action: 'submit' });
            const csrfToken = localStorage.getItem('csrfToken');
            if (!csrfToken) {
              throw new Error('CSRFトークンが取得できませんでした。');
            }
            const response = await fetch(`${API_URL}/contact.php`, {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': csrfToken,
              },
              body: JSON.stringify({ ...this.formData, recaptcha_token: recaptchaToken }),
            });
            const data = await response.json();
            if (data.success) {
              localStorage.removeItem('formData');
              this.$router.push({ name: 'complete' });
            } else {
              alert('送信に失敗しました: ' + data.error);
            }
          } catch (error) {
            alert('エラーが発生しました: ' + error.message);
          }
        },
      },
      created() {
        if (!localStorage.getItem('csrfToken')) {
          fetch(`${API_URL}/csrf_token.php`)
            .then((response) => response.json())
            .then((data) => {
              localStorage.setItem('csrfToken', data.csrf_token);
            })
            .catch(() => alert('CSRFトークンの取得に失敗しました。'));
        }
      },
    };

    // 完了画面
    const CompleteForm = {
      template: `
        <div>
          <h1 class="text-2xl font-bold mb-4">送信が完了しました</h1>
          <p>お問い合わせいただきありがとうございます。</p>
          <button @click="goToInput" class="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">フォームに戻る</button>
        </div>
      `,
      methods: {
        goToInput() {
          this.$router.push({ name: 'input' });
        },
      },
    };

    const routes = [
      { path: '/', name: 'input', component: InputForm },
      { path: '/confirm', name: 'confirm', component: ConfirmForm },
      { path: '/complete', name: 'complete', component: CompleteForm },
    ];

    const router = createRouter({
      history: createWebHistory(),
      routes,
    });

    const App = {
      template: '<router-view></router-view>',
    };

    const app = createApp(App);
    app.use(router);
    app.mount('#app');
  </script>
</body>

</html>

2. config.php

このファイルはプロジェクト全体の設定を一元管理します。メールアドレスや reCAPTCHA キー、CSRFトークンの有効期限などをここに記載します。

<?php
return [
    'recaptcha' => [
        'site_key' => '1234XXXXXXXXXXXXXXXXXXXXXXXXXX', // reCAPTCHAサイトキー(ダミー)
        'secret_key' => '5678XXXXXXXXXXXXXXXXXXXXXXXXXX', // reCAPTCHAシークレットキー(ダミー)
    ],
    'email' => [
        'admin' => 'admin@example.com', // 管理者のメールアドレス(ダミー)
        'from' => 'no-reply@example.com', // メール送信元のアドレス(ダミー)
    ],
    'csrf' => [
        'token_expiry' => 3600, // CSRFトークンの有効期限(秒)
    ],
];

3. contact.php

contact.php は API のエンドポイントとして動作し、フォームデータを処理して保存します。CSV への保存や reCAPTCHA の検証、メール送信も行います。

<?php
session_start();
header('Content-Type: application/json');

// 設定の読み込み
$config = include __DIR__ . '/config.php';

// リクエストデータの取得
$data = json_decode(file_get_contents('php://input'), true);

// CSRFトークンの検証
if (empty($_SESSION['csrf_token']) || empty($_SERVER['HTTP_X_CSRF_TOKEN']) || $_SESSION['csrf_token'] !== $_SERVER['HTTP_X_CSRF_TOKEN']) {
    echo json_encode(['success' => false, 'error' => 'CSRFトークンが無効です。']);
    exit;
}

// reCAPTCHA の検証
$recaptcha_url = 'https://www.google.com/recaptcha/api/siteverify';
$recaptcha_secret = $config['recaptcha']['secret_key'];
$recaptcha_response = $data['recaptcha_token'];

$response = file_get_contents($recaptcha_url . '?secret=' . $recaptcha_secret . '&response=' . $recaptcha_response);
$recaptcha = json_decode($response, true);

if (!$recaptcha['success']) {
    echo json_encode(['success' => false, 'error' => 'reCAPTCHA 検証に失敗しました。']);
    exit;
}

// 入力データのサニタイズとバリデーション
$name = htmlspecialchars($data['name'], ENT_QUOTES, 'UTF-8');
$email = filter_var($data['email'], FILTER_VALIDATE_EMAIL);
$message = htmlspecialchars($data['message'], ENT_QUOTES, 'UTF-8');

if (!$email) {
    echo json_encode(['success' => false, 'error' => 'メールアドレスが無効です。']);
    exit;
}

// 保存先のCSVファイルパス
$csvFile = __DIR__ . '/submissions.csv';

// CSV への保存
try {
    $fileHandle = fopen($csvFile, 'a');
    if (!$fileHandle) {
        throw new Exception('CSVファイルのオープンに失敗しました。');
    }

    $timestamp = date('Y-m-d H:i:s');
    $ipAddress = $_SERVER['REMOTE_ADDR'] ?? '不明';
    $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '不明';

    // CSVデータの構築
    $csvData = [
        $timestamp,
        $name,
        $email,
        $message,
        $ipAddress,
        $userAgent,
    ];

    if (fputcsv($fileHandle, $csvData) === false) {
        throw new Exception('CSVファイルへの書き込みに失敗しました。');
    }

    fclose($fileHandle);
} catch (Exception $e) {
    echo json_encode(['success' => false, 'error' => 'CSV保存エラー: ' . $e->getMessage()]);
    exit;
}

// メール送信処理
$toAdmin = $config['email']['admin'];
$subjectAdmin = 'お問い合わせフォームからのメッセージ';
$messageAdmin = "名前: $name\nメールアドレス: $email\nメッセージ: $message";
$headersAdmin = 'From: ' . $config['email']['from'];

$mailToAdmin = mail($toAdmin, $subjectAdmin, $messageAdmin, $headersAdmin);

// 問い合わせ者への自動返信メール
$toUser = $email;
$subjectUser = 'お問い合わせありがとうございます';
$messageUser = "$name 様\n\nお問い合わせありがとうございます。\n以下の内容で受け付けました。\n\n----------------------\nお名前: $name\nメールアドレス: $email\nお問い合わせ内容:\n$message\n----------------------\n\n担当者より折り返しご連絡いたします。\n\nよろしくお願いいたします。\n";
$headersUser = 'From: ' . $config['email']['from'];

$mailToUser = mail($toUser, $subjectUser, $messageUser, $headersUser);

// レスポンス
if ($mailToAdmin && $mailToUser) {
    echo json_encode(['success' => true]);
} else {
    echo json_encode(['success' => false, 'error' => 'メール送信に失敗しました。']);
}

4. csrf_token.php

CSRF トークンを発行します。

<?php
session_start();

// 設定を読み込む
$config = include __DIR__ . '/config.php';

// CSRFトークンを生成
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    $_SESSION['csrf_token_expiry'] = time() + $config['csrf']['token_expiry'];
}

// トークンが期限切れの場合、新しいトークンを生成
if (time() > $_SESSION['csrf_token_expiry']) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    $_SESSION['csrf_token_expiry'] = time() + $config['csrf']['token_expiry'];
}

// レスポンスに CSRF トークンを含める
echo json_encode(['csrf_token' => $_SESSION['csrf_token']]);

5. recaptcha_key.php

reCAPTCHA サイトキーを提供します。

<?php
header('Content-Type: application/json');

// 設定を読み込む
$config = include __DIR__ . '/config.php';

// reCAPTCHA サイトキーを返す
echo json_encode(['site_key' => $config['recaptcha']['site_key']]);

6. .htaccess

この設定により、URLのパスが適切に処理されます。

# URLのパスをindex.htmlにリダイレクト
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]

まとめ

このプロジェクトでは以下のことを実現しました:

  1. Vue.js を使用したフォームの作成。
  2. CSV への保存 機能。
  3. CSRF 対策reCAPTCHA 検証 の導入。
  4. メール送信 機能。
  5. プロジェクト全体の設定を config.php で一元管理。

コメント

タイトルとURLをコピーしました