CodeIgniter4 & reCAPTCHA でお問い合わせフォームを作成する方法
2024/03/27
以前CodeIgniter4.4.4で簡易的なお問い合わせページを作成したが、今回は「サーバー側でもバリデート対応」「reCAPTCHA v2の追加」「入力確認ページを挟む」「画像の添付機能追加」を追加し、ある程度本番環境でも使えそうなフォームを作成した。以下に対応方法等をメモ。
reCAPTCHAの用意
過去記事を参考の上、Google reCAPTCHAでサイト登録を行いサイトキー及びシークレットキーを発行しておく。今回は動作的にも分かりやすいようv2を選択した。
バリデーションの日本語化
CodeIgniterを用いてサーバ側でバリデートを行った場合、メッセージは英語表記になる。そのままだと実運用の際に困るのでこちらを参考の上で以下準備を行う。
Validation.phpの用意
「/codeigniter4/app/Language/ja」ディレクトリを作成し、以下を記述したValidation.phpをアップロードする。
<?php /** * Validation language strings. * * @package CodeIgniter * @author CodeIgniter Dev Team * @copyright 2019-2020 CodeIgniter Foundation * @license https://opensource.org/licenses/MIT MIT License * @link https://codeigniter.com * @since Version 4.0.0 * @filesource * * @codeCoverageIgnore */ return [ // Core Messages 'noRuleSets' => 'バリデーションに必要なルールが設定されていません。', 'ruleNotFound' => '{0} は有効なルールではありません。', 'groupNotFound' => '{0} はバリデーションルールグループではありません。', 'groupNotArray' => '{0} ルールグループは配列ではありません。', 'invalidTemplate' => '{0} は有効なバリデーションテンプレートではありません。', // Rule Messages 'alpha' => '{field} には、アルファベットのみを入力してください。', 'alpha_dash' => '{field} には、半角英数字、アンダースコア、ダッシュのみを入力してください。', 'alpha_numeric' => '{field} には、半角英数字のみを入力してください。', 'alpha_numeric_punct' => '{field} には、半角英数字、半角スペース、~ ! # $ % & * - _ + = | : . のみを入力してください。', 'alpha_numeric_space' => '{field} には、半角英数字と半角スペースのみを入力してください。', 'alpha_space' => '{field} には、アルファベットと半角スペースのみを入力してください。', 'decimal' => '{field} には、半角で10進数を入力してください。', 'differs' => '{field} には、{param} と異なる値を入力してください。。', 'equals' => '{field} は、{param} と一致しなければなりません。', 'exact_length' => '{field} の長さは {param} 文字でなければなりません。', 'greater_than' => '{field} には、{param} より大きい半角数値を入力してください。', 'greater_than_equal_to' => '{field} には、{param} 以上の半角数値を入力してください。', 'hex' => '{field} には、16進数文字を入力してください。', 'in_list' => '{field} には、{param} のいずれかの値を入力してください。', 'integer' => '{field} には、半角整数を入力してください。', 'is_natural' => '{field} には、半角数字のみを入力してください。', 'is_natural_no_zero' => '{field} には、半角数字のみを、0より大きい値で入力してください。', 'is_not_unique' => '{field} には、データベース内の既存の値が含まれている必要があります。', 'is_unique' => '{field} には、一意の値が含まれている必要があります。', 'less_than' => '{field} には、{param} より小さい半角数値を入力してください。', 'less_than_equal_to' => '{field} には、{param} 以下の半角数値を入力してください。', 'matches' => '{field} は {param} と一致しません。', 'max_length' => '{field} は {param} 文字以内で入力してください。', 'min_length' => '{field} は {param} 文字以上で入力してください。', 'not_equals' => '{field} は {param} と一致しません。', 'numeric' => '{field} には、半角数字のみを入力してください。', 'regex_match' => '{field} の形式が正しくありません。', 'required' => '{field} は、必須入力です。', 'required_with' => '{param} が存在する場合、{field} は必須となります。', 'required_without' => '{param} が存在しない場合、{field} は必須となります。', 'timezone' => '{field} は有効なタイムゾーンではありません。', 'valid_base64' => '{field} には、有効なbase64文字列を入力してください。', 'valid_email' => '{field} は、有効なメールアドレスではありません。', 'valid_emails' => '{field} に、無効なメールアドレスが含まれています。', 'valid_ip' => '{field} には、有効なIPアドレスを入力してください。', 'valid_url' => '{field} には、有効なURLを入力してください。', 'valid_date' => '{field} には、有効な日付を入力してください。', // Credit Cards 'valid_cc_num' => '{field} は、有効なクレジットカード番号ではありません。', // Files 'uploaded' => '{field} は、有効なファイルではありません。', 'max_size' => '{field} は、ファイルサイズが大きすぎます。', 'is_image' => '{field} には、画像ファイルを指定してください。', 'mime_in' => '{field} は、有効なMIMEタイプではありません。', 'ext_in' => '{field} は、有効なファイル拡張子ではありません。', 'max_dims' => '{field} は画像ファイルではないか、幅が広すぎるか高すぎます。', ];
設定ファイルの変更
「application/Config/App.php」を以下の通り変更する。
#変更前 public $defaultLocale = 'en'; #変更後 public $defaultLocale = 'ja';
ルーティング設定
/app/Config/Routes.php
$routes->add('/contact2', 'Contact2::index'); $routes->add('/contact2/confirm', 'Contact2::confirm'); $routes->add('/contact2/send', 'Contact2::send'); $routes->add('/contact2/complete', 'Contact2::complete');
Views
/app/Views/contact2/header.php(ヘッダー)
<!doctype html> <html lang="jp"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-with, initial-scale=1, shrink-to-fit=no"> <meta http-equiv="content-type" content="text/html; charset = UTF-8"> <title><?php echo $header_title;?></title> <?php echo link_tag('https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css'); ?> <?php echo script_tag('https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js'); ?> <?php echo script_tag('https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js'); ?> <?php echo script_tag('https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js'); ?> <?php echo script_tag('https://code.jquery.com/jquery-3.5.1.js'); ?> </head> <body>
</body> </html>
/app/Views/contact2/index.php(お問い合わせページ)
<div id="app"> <div class="container w-75"> <div class="card mt-3 mb-3"> <div class="card-header"><?php echo $page_title;?></div> <div class="card-body"> <?php if( session()->has('msg') ){ //メッセージがある場合は出力?> <div class="alert alert-danger" role="alert"> <?php echo session()->getFlashdata('msg');?> </div> <?php } ?> <?php echo form_open_multipart('contact2/confirm', 'id="test-form"');?> <div class="mb-3"> <?php echo form_label('名前 <span class="badge bg-danger">必須</span>', 'name', array('class' => 'form-label fw-bold'));?> <?php echo form_input(array('class' => 'form-control', 'id' => 'name', 'name' => 'name', 'type' => 'text', 'value' => $session_data['name']));?> <p class="text-danger alert-area" id="alert-name">名前が入力されていません。</p> </div> <div class="mb-3"> <?php echo form_label('メールアドレス <span class="badge bg-danger">必須</span>', 'mail', array('class' => 'form-label fw-bold'));?> <?php echo form_input(array('class' => 'form-control', 'id' => 'mail', 'name' => 'mail', 'type' => 'text', 'value' => $session_data['mail']));?> <p class="text-danger alert-area" id="alert-mail">メールアドレスが入力されていません。</p> </div> <div class="mb-3"> <?php echo form_label('本文 <span class="badge bg-danger">必須</span>', 'body', array('class' => 'form-label fw-bold'));?> <?php echo form_textarea(array('class' => 'form-control', 'id' => 'body', 'name' => 'body', 'value' => $session_data['body'], 'rows' => '5'));?> <p class="text-danger alert-area" id="alert-body">本文が入力されていません。</p> </div> <div class="mb-3"> <?php echo form_label('問い合わせ項目', 'item', array('class' => 'form-label fw-bold'));?> <?php foreach( $form_data['item'] as $key => $val ){ ?> <div class="form-check"> <?php $checked = ( in_array( $val, (array)$session_data['item'] ) ) ? true : false;?> <?php echo form_checkbox(array('class' => 'form-check-input', 'id' => 'item_' . $key, 'name' => 'item[]', 'value' => $val, 'checked' => $checked));?> <?php echo form_label($val, 'item_' . $key, array('class' => 'form-check-label'));?> </div> <?php } ?> </div> <div class="mb-3"> <?php echo form_label('返信について <span class="badge bg-danger">必須</span>', 'reply', array('class' => 'form-label fw-bold'));?> <?php foreach( $form_data['reply'] as $key => $val ){ ?> <div class="form-check"> <?php $checked = ( $session_data['reply'] == $val ) ? true : false;?> <?php echo form_radio(array('class' => 'form-check-input', 'id' => 'reply_' . $key, 'name' => 'reply', 'value' => $val, 'checked' => $checked));?> <?php echo form_label($val, 'reply_' . $key, array('class' => 'form-check-label'));?> </div> <?php } ?> <p class="text-danger alert-area" id="alert-reply">返信についてが選択されていません。</p> </div> <div class="mb-3"> <?php echo form_label('業種 <span class="badge bg-danger">必須</span>', 'job', array('class' => 'form-label fw-bold'));?> <?php echo form_dropdown('job', $form_data['job'], $session_data['job'], array('class' => 'form-control', 'id' => 'job'));?> <p class="text-danger alert-area" id="alert-job">業種についてが選択されていません。</p> </div> <div class="mb-3"> <?php echo form_label('画像', 'img', array('class' => 'form-label fw-bold'));?> <?php echo form_upload(array('class' => 'form-control', 'id' => 'img', 'name' => 'img'));?> <p class="text-danger alert-area" id="alert-img"></p> </div> <div class="text-center pt-3 col-md-6 offset-md-3 mb-4"> <?php echo form_checkbox(array('class' => 'form-check-input', 'id' => 'terms', 'name' => 'terms', 'value' => '1'));?> <label class="form-check-label fw-bold" for="terms">利用規約に同意します。</label> <p class="text-danger alert-area" id="alert-terms">利用規約に同意ください</p> </div> <div class="d-grid gap-2 mx-auto mb-2" style="width: 300px;"> <div id="recaptcha_area"></div> </div> <div class="d-grid gap-2"> <?php echo form_hidden('token', $token);?> <button class="btn btn-primary" id="confirm" type="button">入力内容確認</button> </div> <?php echo form_close();?> </div> </div> </div> </div> <?php echo script_tag(['src' => 'https://www.google.com/recaptcha/api.js?onload=recaptcha&render=explicit', 'defer' => null, 'async' => null]); ?> <script> var recaptcha = () => { grecaptcha.render('recaptcha_area', { 'sitekey' : '<?php echo $recaptcha_data["site_key"];?>' }); }; $(function(){ $('.alert-area, #confirm').hide(); $('#terms').on('change', function(){ if( $(this).is(':checked') ){ $('#confirm').show(); }else{ $('#confirm').hide(); } }); $('#confirm').on('click', function(){ const name = $('#name').val(); const mail = $('#mail').val(); const body = $('#body').val(); const reply = $('input[name="reply"]:checked').val(); const job = $('#job').val(); const img = $('#img').val(); let scroll = ''; $('.alert-area').hide(); if( !name ){ $('#alert-name').show(); if( !scroll ) scroll = 'name'; } if( !mail ){ $('#alert-mail').show(); if( !scroll ) scroll = 'mail'; } if( !body ){ $('#alert-body').show(); if( !scroll ) scroll = 'body'; } if( job == 0 ){ $('#alert-job').show(); if( !scroll ) scroll = 'job'; } if( !reply ){ $('#alert-reply').show(); if( !scroll ) scroll = 'reply'; } if( img ){ if( $('#img').prop('files')[0].size > <?php echo $form_data['img']['max_size'];?> ){ $('#alert-img').show().html(`<?php echo $form_data['img']['max_size_text'];?> 以内の画像を選択してください。`); if( !scroll ) scroll = 'reply'; }else if( !$('#img').prop('files')[0].type.match(/^image/) ){ $('#alert-img').show().html('画像以外のファイルはアップロードできません。'); if( !scroll ) scroll = 'reply'; } } if( scroll ){ $('html, body').animate({scrollTop: $('#' + scroll).offset().top} ); }else{ $('#test-form').submit(); } return false; }); }) </script>
reCAPTCHA部分
reCAPTCHA部分に関しては過去記事を参照。
label部分
labelタグ部分はform_labelというForm Helperになる。
CSRF対策
CodeIgniterでCSFR対策を行う場合こちらの方法が定番みたいだが、どうも挙動が気になったので素のPHPで行う方法を取った。
/app/Views/contact2/confirm.php(入力内容確認ページ)
<div id="app"> <div class="container w-75"> <div class="card mt-3 mb-3"> <div class="card-header"><?php echo $page_title;?></div> <div class="card-body"> <?php echo form_open_multipart('contact2/send', 'id="test-form"');?> <div class="mb-3"> <?php echo form_label('名前', 'name', array('class' => 'form-label fw-bold'));?> <p> <?php echo esc($post_data['name']);?> <?php echo form_hidden('name', esc($post_data['name']));?> </p> </div> <div class="mb-3"> <?php echo form_label('メールアドレス', 'mail', array('class' => 'form-label fw-bold'));?> <p> <?php echo esc($post_data['mail']);?> <?php echo form_hidden('mail', esc($post_data['mail']));?> </p> </div> <div class="mb-3"> <?php echo form_label('本文', 'body', array('class' => 'form-label fw-bold'));?> <p> <?php echo esc($post_data['body']);?> <?php echo form_hidden('body', esc($post_data['body']));?> </p> </div> <div class="mb-3"> <?php echo form_label('問い合わせ項目', 'item', array('class' => 'form-label fw-bold'));?> <?php $item = implode(',', $post_data['item']); ?> <p> <?php echo esc($item);?> <?php echo form_hidden('item', esc($item));?> </p> </div> <div class="mb-3"> <?php echo form_label('返信について', 'reply', array('class' => 'form-label fw-bold'));?> <p> <?php echo esc($post_data['reply']);?> <?php echo form_hidden('reply', esc($post_data['reply']));?> </p> </div> <div class="mb-3"> <?php echo form_label('業種', 'job', array('class' => 'form-label fw-bold'));?> <p> <?php echo esc($form_data['job'][$post_data['job']]);?> <?php echo form_hidden('job', esc($form_data['job'][$post_data['job']]));?> </p> </div> <div class="mb-3"> <?php echo form_label('画像', 'img', array('class' => 'form-label fw-bold'));?> <p> <?php if( $img_url ){ ?> <?php echo img($img_url, false, array( 'width' => 100, 'height' => 100,));?> <?php }else{ ?> 未選択 <?php } ?> <?php echo form_hidden('img', $img_path);?> </p> </div> <div class="d-grid gap-2"> <?php echo form_hidden('token', $post_data['token']);?> <button class="btn btn-danger" id="back" type="button">前のページに戻る</button> <button class="btn btn-primary" id="complete" type="button">送信</button> </div> </form> </div> </div> </div> </div> <script> $(function(){ $('#back').on('click', function(){ location.href = '../contact2'; return false; }); $('#complete').on('click', function(){ $('#test-form').submit(); return false; }); }); </script>
hidden部分はform_hiddenというForm Helperになる。
/app/Views/contact2/complete.php(送信完了ページ)
<div id="app"> <div class="container w-75"> <div class="card mt-3 mb-3"> <div class="card-header"><?php echo $page_title;?></div> <div class="card-body"> <div class="alert alert-primary" role="alert"> 送信成功しました。 </div> </div> </div> </div> </div>
Controllers
/app/Controllers/Contact2.php
<?php namespace App\Controllers; class Contact2 extends BaseController { public $form_data = ''; public $recaptcha_data = ''; public $session; public function __construct(...$params) { helper(['url','html','form']); $this->session = session(); //フォーム用のデータ $this->form_data = array( 'item' => array( 0 => '資料請求', 1 => 'その他', ), 'reply' => array( 0 => '返信希望', 1 => '不要', ), 'job' => array( 0 => '選択してください', 1 => '水産・農林業', 2 => '鉱業', 3 => '建設業', ), 'img' => array( 'max_size_text' => '1MB', 'max_size' => 1024 * 1024 * 1, ), 'label' => array( 'name' => 'お名前', 'mail' => 'メールアドレス', 'body' => '本文', 'reply' => '返信について', 'job' => '業種', ), ); //reCAPTCHA用のデータ $this->recaptcha_data = array( 'site_key' => 'xxxxxxxxxxxxxxxxxxxxxxxx', 'secret_key' => 'xxxxxxxxxxxxxxxxxxxxxxxx', ); } //内容入力ページ public function index() { $token = bin2hex(random_bytes(32)); $this->session->set('token', $token); $data = [ 'header_title' => 'お問い合わせページ | ci4のテスト', 'page_title' => 'お問い合わせ', 'form_data' => $this->form_data, 'recaptcha_data' => $this->recaptcha_data, 'session_data' => $this->session->get(), 'token' => $token, ]; echo view('contact2/header', $data); echo view('contact2/index', $data); echo view('contact2/footer'); } //内容確認ページ public function confirm() { //セッションにデータ格納 $this->session->set($this->request->getPost()); //tokenチェック if( $this->request->getPost('token') != $this->session->get('token') ){ session()->setFlashdata('msg', 'トークンが一致しません。'); return redirect()->to('/contact2'); } //reCAPTCHA用のパラメータが無い場合 if( !$this->request->getPost('g-recaptcha-response') ){ session()->setFlashdata('msg', 'reCAPTCHA認証できませんでした。'); return redirect()->to('/contact2'); } //reCAPTCHAチェック $contact2_models = new \App\Models\Contact2(); $param = array( 'g-recaptcha-response' => $this->request->getPost('g-recaptcha-response'), 'secret_key' => $this->recaptcha_data['secret_key'], ); $flg = $contact2_models->recaptchaCheck( $param ); if( !$flg ){ session()->setFlashdata('msg', 'reCAPTCHA認証に失敗しました。'); return redirect()->to('/contact2'); } //バリデート用ルール $rules = [ 'name' => 'required', 'mail' => 'required|valid_email', 'body' => 'required', 'reply' => 'required', 'job' => 'required', ]; //バリデートNGの場合 if( !$this->validate($rules) ){ //フラッシュデータ用にメッセージの整形 $msg = ''; foreach( (array)$this->validator->getErrors() as $key => $val ){ $msg .= '<div>' . str_replace($key, $this->form_data['label'][$key], $val) . '</div>'; } session()->setFlashdata('msg', $msg ); return redirect()->to('/contact2'); } //画像のアップロード処理 $img_url = ''; $img_path = ''; if( $file = $this->request->getFile('img') ){ //HTTP経由でエラーなくアップロードされたことを確認 if( $file->isValid() ){ //ランダムな名前に変更 $new_name = $file->getRandomName(); //ファイルを移動できた場合 if( $file->move(FCPATH . 'uploads/contact2', $new_name) ){ $img_url = base_url() . 'uploads/contact2/' . $new_name; $img_path = FCPATH . 'uploads/contact2/' . $new_name; }else{ session()->setFlashdata('msg', '画像のアップロードに失敗しました。'); return redirect()->to('/contact2'); } } } $data = [ 'header_title' => 'お問い合わせページ | 内容確認ページ | ci4のテスト', 'page_title' => 'お問い合わせ - 内容確認ページ', 'form_data' => $this->form_data, 'post_data' => $this->request->getPost(), 'img_url' => $img_url, 'img_path' => $img_path, ]; echo view('contact2/header', $data); echo view('contact2/confirm', $data); echo view('contact2/footer'); } //送信処理 public function send() { //tokenチェック if( $this->request->getPost('token') != $this->session->get('token') ){ session()->setFlashdata('msg', 'トークンが一致しません。'); return redirect()->to('/contact2'); } $flg = false; if( $this->request->getPost() ){ $contact_models2 = new \App\Models\Contact2(); $flg = $contact_models2->sendMail( $this->request->getPost(), $this->form_data ); } if( !$flg ){ session()->setFlashdata('msg', 'メール送信時にエラーが発生しました。'); return redirect()->to('/contact2'); }else{ return redirect()->to('/contact2/complete'); } } //送信完了ページ public function complete() { unset($_SESSION); session_destroy(); $data = [ 'header_title' => 'お問い合わせページ | 送信完了ページ | ci4のテスト', 'page_title' => 'お問い合わせ - 送信完了ページ', ]; echo view('contact2/header', $data); echo view('contact2/complete', $data); echo view('contact2/footer'); } }
reCAPTCHA用のデータのサイトキーとシークレットキー部分は適宜変更する。
尚、この時点で「https://test.com/codeigniter4/contact2」をブラウザから開くと以下のようなページが表示される筈。
Models
/app/Models/Contact2.php
<?php namespace App\Models; use CodeIgniter\Model; class Contact2 extends Model { //コンストラクタ function __construct() { parent::__construct(); } function recaptchaCheck( $param ) { if( !$param['g-recaptcha-response'] || !$param['secret_key'] ){ return false; } $res = file_get_contents('https://www.google.com/recaptcha/api/siteverify?secret=' . $param['secret_key'] . '&response=' . $param['g-recaptcha-response']); $result = json_decode($res); if( $result->success ){ return true; }else{ return false; } } //メール送信 function sendMail($param, $form_data) { $email = \Config\Services::email(); $email->setFrom('xxxxxxxxxxxx', '送信者名'); $email->setTo('xxxxxxxxxxxx'); $email->setSubject('お問い合わせ'); $body = ' 【名前】 ' . $param['name'] . ' 【メール】 ' . $param['mail'] . ' 【本文】 ' . $param['body'] . ' 【問い合わせ項目】 ' . $param['item'] . ' 【返信について】 ' . $param['reply'] . ' 【業種】 ' . $param['job'] . ' ------------------ メールフッター '; $email->setMessage($body); //画像がある場合は添付 if( $param['img'] ){ $email->attach($param['img']); } $flg = ( $email->send() ) ? true : false; return $flg; } }
画像添付について
画像添付については「$email->attach('画像パスもしくはURL');」を使う。
所感
ファイル名について
Views内のファイル名は何でもいいと思うんだけどModels / Controllersは〇〇_Models / 〇〇_Controllersにした方が分かりやすいかも。同じ名前だとエディタのタブ部分の名前を見る際にこれどっちだっけ? と一瞬考えてしまうことが多かった。
日本語情報が少ない
何か困った際にGoogle検索するも日本語情報が少なく感じた。CodeIgniterがある程度触れるになったらもう少し日本語情報が多そうなLaravelを試したいところ。
関連記事
-
CodeIgniter4で簡易版ログインシステムの実装方法(管理画面向け)
CodeIgniter4で管理画面向けの簡易版ログインシステムを作成したい。通常 ...
-
CodeIgniter4で祝日一覧APIにCURLでリクエストし結果をファイルキャッシュする方法
Codeigniter4.4.4で祝日一覧APIにCURLでリクエストしたい。尚 ...
-
CodeIgniter4&Bootstrap&jQueryで簡易版お問い合わせページの作成
CodeIgniter4.4.4&Bootstrap&jQuer ...
-
CodeIgniter3でファイルキャッシュする方法
CodeIgniterでファイルキャッシュが楽に導入できた。そこそこ使いそうなの ...
-
Codeigniter4で独自・外部ライブラリの作成と呼び出し方法
Codeigniter4で独自ライブラリを作成し、コントローラー側で呼び出したい ...