Analog Studio

PHPでSQLを使わない簡易的なコメント機能を実装しよう!

概要

Web サイトに簡易的なコメント機能を実装してみましたので、紹介します。
SQL は使用せずにテキストでデータ管理します。

ログインなども必要とせずにただちょっとコメントするだけという簡単なもので多くのコメントを捌くような使い方は想定していません。
また、返信機能なども特にありませんので必要なら改造してみて下さい。

コンセプト

今回紹介するコメント機能のコンセプトというか大前提は「簡単、軽量、早い」です。
なにもしない代わりにどんなページにも直ぐに組み込めるように考えて作りしました。
PHP 以外には SQL など使用しませんので SQL が使えない (無料) サーバでも使えます。

また、ユーザにも手を煩わせないようにコメントするのに必要なのはコメント内容のみです。
名前は不要ですが、セッション ID を細工した文字列で同一発信者を識別可能です。

完成したコード

いつも通り、まずは完成したコードを以下に示します。
解説等は特に要らない方はここまでで結構です。自由にお使い下さい。

<コメント埋込部(PHP)> // HTMLを出力する前にsession_start();しておいて下さい // ID生成にsession_id()を使います // 以下をPHPファイルに直接埋め込むか別ファイルに記述しておきrequire()やinclude()で呼び出して下さい <?php // このPHPファイルをrequireした呼び出し元のディレクトリとファイル名を取得する // 直接埋め込む場合は"$r_buff[0]['file']"を"__FILE__"に置き換えて下さい $r_buff = @debug_backtrace(); if(isset($r_buff[0])){ $r_name = preg_replace('/\.php\z/', '.dat', $r_buff[0]['file']); } else{ $r_name = false; } // コメントを取得する if($r_name){ if(file_exists($r_name)){ $comments = json_decode(file_get_contents($r_name), true); } else{ $comments = array(); } } // 最後の10件だけ表示する $comments = array_slice($comments , 0, 10); ?> <aside id="show_comments"> <div> <form> <h4>コメントする</h4> <div> <label for="comment_name">お名前</label> <input type="text" name="name" id="comment_name" placeholder="お名前 (省略可)"> <input type="hidden" name="id" value="<?php echo substr(base_convert(md5(session_id()), 16, 36), 0, 6); ?>"> </div> <div> <label for="comment_mail">メールアドレス</label> <input type="text" name="mail" id="comment_mail" placeholder="メールアドレス (省略可)"> </div> <div> <label for="comment_website">Web サイト</label> <input type="text" name="url" id="comment_website" placeholder="WebサイトなどのURL (省略可)"> </div> <div> <label for="comment_text">コメント (必須)</label> <textarea id="comment_text" name="message" placeholder="コメントやご感想など" required></textarea> </div> <div> <button type="submit" id="comment_button">コメントを書き込む</button> </div> </form> </div> <div> <small>※短時間で連続してのコメントはできません。</small> <small>※いたずら防止のためにIP アドレスを記録しています。ご了承願います。</small> </div> <?php if(count($comments) > 0){ ?> <ul> <?php foreach($comments as $msg){ ?> <li> <div> <span><?php if(!empty($msg['mail'])){ echo '<a href="mailto:'. $msg['mail']. '" rel="noopener" target="new">'; } ?><?php echo empty($msg['name']) ? 'ゲスト' : $msg['name']; ?><?php if(!empty($msg['mail'])){ echo '</a>'; } ?>さん</span> <span>(ID:<?php echo $msg['id']; ?>)</span> <span>投稿日時:<?php echo $msg['time']; ?></span> <?php if(!empty($msg['url'])){ ?> <span><a href="<?php echo $msg['url']; ?>" rel="noopener" target="new"><?php echo $msg['url']; ?></a></span> <?php } ?> </div> <p> <?php echo $msg['message']; ?> </p> </li> <?php } ?> </ul> <?php } ?> </aside>
<CSS> /* 適宜好きなように変えて下さい */ /* Fontawesome 5 Free を使用しています */ /* コメント */ /* 入力欄 */ #show_comments, #show_comments div, #show_comments label{ display : block; padding : 0px; } #show_comments h4{ margin : 44px 0px 16px 0px; padding : 0px; font-size : 19px; line-height : 32px; font-weight : 700; border-bottom : none; } #show_comments h4::before{ display : inline-block; content : '\f075'; font-family : 'Font Awesome 5 Free'; font-weight : 400; font-size : 20px; padding-right : 0.3em; } #show_comments input, #show_comments textarea{ display : block; padding : 5px 10px; width : 100%; max-width : 390px; border : 1px solid #696969; border-radius : 5px; box-sizing : border-box; } #show_comments textarea{ max-width : 100%; height : 120px; } #show_comments button{ display : block; margin : 20px auto; padding : 5px 10px; width : 200px; height : 50px; line-height : 38px; color : #7A3E3E; text-align : center; background-color : #F1E3E3; border : 1px solid #C37272; box-sizing : border-box; border-radius : 25px; cursor : pointer; transition : 0.5s; } #show_comments button:hover{ background-color : #F7E9D4; } #show_comments div > small{ display : block; font-size : 14px; color : #969696; text-align : center; } /* コメント一覧 */ #show_comments ul{ display : block; margin : 10px 0px; padding : 5px; max-height : 320px; overflow : auto; border : 1px dotted #B4B4B4; border-radius : 5px; box-sizing : border-box; list-style-type : none; opacity : 0.9; } #show_comments ul::-webkit-scrollbar{ width : 10px; } #show_comments ul::-webkit-scrollbar-track{ border-radius : 5px; box-shadow : inset 0 0 6px rgba(0,0,0,0.1); } #show_comments ul::-webkit-scrollbar-thumb{ background-color : rgba(0,0,50,0.5); border-radius : 5px; box-shadow : 0 0 0 1px rgba(255,255,255,0.3); } #show_comments ul > li{ display : block; margin : 10px 0px; padding : 5px; border : 1px solid #969696; border-radius : 5px; } #show_comments ul > li > div{ line-height : 0px; border-bottom : 1px dotted #B4B4B4; } #show_comments ul > li > div *{ font-size : 14px; line-height : 17px; } #show_comments ul > li span{ display : inline-block; padding : 2px 5px; max-width : 100%; color : #696969; white-space : nowrap; text-overflow : ellipsis; overflow : hidden; box-sizing : border-box; } #show_comments ul > li p{ margin : 5px; font-size : 14px; line-height : 20px; }
<Javascript> // XHRのみ対応 // 普通にフォームのPOST送信で対応すればJavascriptは不要 // DOMが構築できたらイベント設定 window.addEventListener("DOMContentLoaded", setCommentFunc); // ブログの簡易コメント機能 const setCommentFunc = function(){ const commentsEle = document.getElementById("show_comments"); if(commentsEle){ document.getElementById("comment_button").addEventListener("click", function($e){ $e.preventDefault(); const XHR = new XMLHttpRequest(); XHR.open("POST", "[コメントを書き込むPHPファイルのディレクトリ]/xhr_comment_send.php", true); XHR.send(new FormData(commentsEle.getElementsByTagName("form")[0])); XHR.onreadystatechange = function(){ if(XHR.readyState === 4 && XHR.status === 200){ // console.log(XHR.responseText); if(!XHR.responseText.match(/^Failed/)){ let commentsUL = commentsEle.getElementsByTagName("ul"); if(commentsUL.length > 0){ commentsUL = commentsUL[0]; } else{ commentsUL = commentsEle.appendChild(document.createElement("ul")); } const newComment = commentsUL.insertBefore(document.createElement("li"), commentsUL.firstChild); newComment.innerHTML = XHR.responseText; } } }; return false; }, false); } }
<コメントを書き込むためのPHPファイル(xhr_comment_send.php)> // 任意のディレクトリに置く // 上記JavascriptからこのファイルにPOST送信する <?php $referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : null; $referer = preg_replace('/[\?#][\s\S]*\z/', '', $referer); if(!preg_match('/アクセス元のアドレスを正規表現で/', $referer)){ // 他ドメインからのアクセスは拒否する exit("Bad Request!!"); } // POSTメソッドじゃなかったら終了 if($_SERVER['REQUEST_METHOD'] !== 'POST'){ exit('Failed getting POST data.'); } // TOPページのURL // "index.html"などのファイル名は書かない $top_url = 'https://web.analogstd.com/'; // このファイルからTOPページがあるディレクトリまでの相対パス $top_path = '../../'; // 書き出しファイルを呼び出し元から取得する // 出力先は表示ページと同じディレクトリの同じ名前で拡張子が".dat" $buff = explode('/', (__DIR__. '/')); $m = count(explode('../', $top_path)); array_splice($buff, (0 - $m)); $input = implode('/', $buff). '/'. str_replace($top_url, '', $referer); $output = preg_replace('/\.php\z/', '.dat', $input); // 現状のデータを読み込む if(file_exists($output)){ $data = @json_decode(@file_get_contents($output), true); } else{ $data = array(); } if(!is_array($data)){ exit('Failed loading current data.'); } // POSTデータを取得する $p_data = array(); foreach($_POST as $k => $v){ switch($k){ case 'name': case 'message': // タグを全て削除 $p_data[$k] = preg_replace('/<[^>]*>/', '', $v); // 改行を全て<BR>に置き換える $p_data[$k] = preg_replace('/\n/', '<BR>', $p_data[$k]); break; case 'id': // IDが英数字6文字であることを確認する if(!preg_match('/\A[a-z0-9]{6}\z/', $v)){ exit('Failed getting ID code.'); } $p_data[$k] = $v; break; case 'mail': // メールアドレスの形式を確認する if(filter_var($v, FILTER_VALIDATE_EMAIL)){ $p_data[$k] = $v; } else{ $p_data[$k] = ''; } break; case 'url': // サイトアドレスの形式を確認する if(filter_var($v, FILTER_VALIDATE_URL)){ $p_data[$k] = $v; } else{ $p_data[$k] = ''; } break; } } // 名前に"管理"や"admin","root"といった文字が含まれた場合は名前を削除する $p_data['name'] = mb_convert_kana($p_data['name'], 'a'); if(preg_match('/(管理|admin|root)/i', $p_data['name'])){ $p_data['name'] = ''; } // コメント内容が空でないことを確認する if(empty(preg_replace('/(\s|\n|\r|<BR>)/', '', $p_data['message']))){ exit('Failed getting comment.'); } // 同じIDが短時間でアクセスしていないか確認する if(count($data) > 0){ if(strtotime('now') - strtotime($data[0]['time']) < 60){ exit('Failed sending comment.'); } } // コメントを配列に追加する array_unshift($data, array_merge($p_data, array('time' => date('Y-m-d H:i:s'), 'ip' => $_SERVER['REMOTE_ADDR']))); // ファイルに出力する $result = file_put_contents($output, json_encode($data), LOCK_EX); // 結果を返す if($result === false){ exit('Failed writing file.'); } else{ // 管理者にメールを送信する send__mail('管理者', 'コメントされた時に送信するアドレス', '', '', '差出人のアドレス(架空でもOK)', '', 'ブログにコメントされました', str_replace('<BR>', "\n", $data[0]['message']). "\n\n{$referer}#show_comments"); // HTMLをJavascriptに返す echo comment_html($data[0]); } ////////// /* 関数 */ ////////// // メール送信の処理関数 // 戻り値は送信受付可否(true/false) function send__mail($to_name, $to_address, $cc, $bcc, $from, $reply, $title, $body){ // 文字コード変換 // 文字コードは"ISO-2022-JP"(S-JIS)に変換する $to = mb_convert_encoding($to_address, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $cc = mb_convert_encoding($cc, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $bcc = mb_convert_encoding($bcc, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $from = mb_convert_encoding($from, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $title = mb_convert_encoding($title, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $body = mb_convert_encoding($body, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); // 件名に日本語が使えるようにエンコードする // (mb_send_mailを使わずにmailを使う為) $title = '=?iso-2022-jp?B?'. base64_encode($title). '?='; // 送信情報の成形 $send_to = mb_encode_mimeheader($to_name, 'ISO-2022-JP'). "<$to>"; $headers = array(); $headers[] = 'From: '. $from; $headers[] = 'Cc: '. $cc; $headers[] = 'Bcc: '. $bcc; $headers[] = 'Reply-To: '. $reply; $headers[] = 'Return-Path: '. $reply; $headers[] = 'Content-Type: text/plain; charset=ISO-2022-JP'; // $headers[] = 'Message-Id: <'. md5(uniqid(microtime())). '@web.analogstd.com>'; $headers[] = 'X-Mailer: PHP/'. phpversion(); $pfrom = '-f $from'; $smresult = mail($send_to, $title, $body, implode("\n", $headers), $pfrom); return $smresult; } // コメント部のHTMLを出力する関数 function comment_html($msg){ ob_start(); // 標準出力のバッファ開始 // <li>~</li>の中身を出力する ?> <div> <span><?php if(!empty($msg['mail'])){ echo '<a href="mailto:'. $msg['mail']. '" rel="noopener" target="new">'; } ?><?php echo empty($msg['name']) ? 'ゲスト' : $msg['name']; ?><?php if(!empty($msg['mail'])){ echo '</a>'; } ?>さん</span> <span>(ID:<?php echo $msg['id']; ?>)</span> <span>投稿日時:<?php echo $msg['time']; ?></span> <?php if(!empty($msg['url'])){ ?> <span><a href="<?php echo $msg['url']; ?>" rel="noopener" target="new"><?php echo $msg['url']; ?></a></span> <?php } ?> </div> <p> <?php echo $msg['message'], "\n"; ?> </p> <?php $html = ob_get_contents(); ob_end_clean(); // バッファここまで // 出力するHTMLを返す return $html; } ?>

実行結果

このページの最下部にあるので、良かったらコメントを残していって下さいね。

コードの解説

では簡単に解説します。
分からないところがあれば、コメントかお問合せフォームよりお願いします。

表示するPHPコード

コメントのデータは呼び出し元の PHP ファイルと同じディレクトリ・同じ名前 (拡張子違い) に保存するので、保存先のフルパスを取得します。

表示する PHP ファイルに直接コードを書き込んでいる場合は、マジカル変数の "__FILE__" を使えば簡単にフルパスが取得できるので末尾の拡張子を ".php" から ".dat" に置換するだけです。

include や require で呼び出す場合は "debug_backtrace()" という関数を使います。
この関数は、返り値に include などで呼び出した元ファイルの情報 (ファイルのフルパスや呼出関数名、コードの行数) が配列で返ってきます。
入れ子で何重にも呼び出している場合は順番に格納されています。
ですので、ここから呼び出し元のファイルパスを取得して拡張子を変えればOKです。

// includeやrequireした時 $r_buff = @debug_backtrace(); if(isset($r_buff[0])){ $r_name = preg_replace('/\.php\z/', '.dat', $r_buff[0]['file']); } else{ $r_name = false; } // PHPに直接埋め込んだ場合 $r_name = preg_replace('/\.php\z/', '.dat', __FILE__);

あとは、file_get_contents() 関数でテキストを読みだして、json_decode() 関数で配列化すればOKです。
json_decode() の第二引数を省略したり "false" にするとオブジェクトになるので、使いやすい方でどうぞ。

HTML への出力は好きなコードを出力すれば良いです。

同一人物の識別のためにフォームの隠し要素にセッション ID から生成した ID を埋め込んでいます。
セッション ID をそのまま使うわけにはいかないので md5() 関数でハッシュ化してその一部を ID としています。

// セッションIDをMD5でハッシュ化して、先頭の6文字をIDとして使う echo substr(base_convert(md5(session_id()), 16, 36), 0, 6);

JavascriptでXHRを使ってPOST送信

詳しいことは "Javascriptでページ遷移せずにPOST送信する方法" にまとめているので、こちらの記事も参考にして下さい。

POST 送信すると受信用の PHP ファイルでデータのチェックとファイルへの書き込みを行います。(詳しくは次項)
書き込みが成功すればコメント表示用の HTML が返ってきますので表示部にコメントを追加します。

Javascript で DOM を全て構築しても良いのですが、煩雑になるので PHP で整形してもらってそれを書き出すだけにしています。
もちろん、JSON などのデータを受け取って Javascript で要素を作るようにしても大丈夫です。

// XHRのレスポンス状態が変化した時にイベント発火 XHR.onreadystatechange = function(){ // 正常に受信完了した場合に以下実行 if(XHR.readyState === 4 && XHR.status === 200){ // データが悪かったり短時間で連続投稿しようとした時は"Failed ~~"が返ってくる if(!XHR.responseText.match(/^Failed/)){ // コメントエリア内の<UL>要素コレクションを取得する let commentsUL = commentsEle.getElementsByTagName("ul"); if(commentsUL.length > 0){ // <UL>要素があればそのセレクタに置き換える commentsUL = commentsUL[0]; } else{ // <UL>要素がなければ作成する commentsUL = commentsEle.appendChild(document.createElement("ul")); } // コメント用の<LI>要素を作成して先頭に追加する const newComment = commentsUL.insertBefore(document.createElement("li"), commentsUL.firstChild); // 受け取ったHTMLコードを出力する newComment.innerHTML = XHR.responseText; } } };

POSTデータを受け取ってファイルに書き出す

最後にコメントデータを受け取る PHP ファイルです。
こちらのファイルでは POST データを受信・確認して、問題なければファイルへの書き出し、コメント表示用の HTML 生成を行います。

まずは、POST データの送信元を確認します。
他所のサーバから送信されてきても無視するようにしておきましょう。
また、POST 以外の送信方法も対応していないので無視します。

続いて、送信元のアドレス (例:https://~~) からサーバ内のパス (例:/home/…/public_html/~~) に変換します。
そのために、"サイトの TOP ページレベルの URL" と自身のファイルパスから見た "サイトの TOP ページレベルの相対パス" を設定しておきます。
この二つとアクセス元の URL から書き出しファイルのファイルパスを取得します。

$referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : null; $referer = preg_replace('/[\?#][\s\S]*\z/', '', $referer); // TOPページのURL // "index.html"などのファイル名は書かない $top_url = 'https://web.analogstd.com/'; // このファイルからTOPページがあるディレクトリまでの相対パス $top_path = '../../'; // 書き出しファイルを呼び出し元から取得する // 出力先は表示ページと同じディレクトリの同じ名前で拡張子が".dat" $buff = explode('/', (__DIR__. '/')); $m = count(explode('../', $top_path)); array_splice($buff, (0 - $m)); $input = implode('/', $buff). '/'. str_replace($top_url, '', $referer); $output = preg_replace('/\.php\z/', '.dat', $input);

上でファイルパスが分かったのでデータを読み込んで JSON を配列に展開しておきます。
続いて、POST データの確認 (バリデーション) をして短時間のアクセスでないことも確認します。

ここまでくれば内容に問題はないはずなのでデータをファイルに書き出していきます。
array_unshift() 関数と array_merge() 関数を使って既存のデータに追記します。
ファイルへの書き出し中は一応、念のためにロックしておきます。(file_put_contents() 関数の第三引数に "LOCK_EX" を指定)

// コメントを配列に追加する array_unshift($data, array_merge($p_data, array('time' => date('Y-m-d H:i:s'), 'ip' => $_SERVER['REMOTE_ADDR']))); // ファイルに出力する // 出力する時は配列をJSONデータに変換しておく $result = file_put_contents($output, json_encode($data), LOCK_EX);

$result にはファイルの書き出し成否の論理値が入っているので成功していれば、管理者にメールして HTML を出力します。
HTML は標準出力をバッファ (ブラウザに出力しないでメモリに待機させておく) して変数に収めていますが、コードが見にくくなっても良ければそのまま標準出力 (echo など) させても大丈夫です。

ob_start(); // これ以降の標準出力はブラウザに出力されずにメモリにバッファされます /**********************/ echo '適当なHTMLを出力'; /* …(中略)… */ echo '適当なHTMLを出力'; /**********************/ // バッファした標準出力を変数に取り出す $html = ob_get_contents(); ob_end_clean(); // バッファ終了

メールは自前のマルチバイトに対応した関数を使って送信します。
mb_send_mail() 関数で自動的にエンコードしてもらっても良いですが、色々と面倒なのと添付ファイルを送れないので mail() 関数で送れるようにエンコードを明示的に行って送信するようにしています。
mb_send_mail() で文字化けに悩んでいる方はまずはマニュアルを、そして以下のような情報を参考にして下さい。
マニュアルは必ず読んだ方が良いです。

// メール送信の処理関数 // 戻り値は送信受付可否(true/false) function send__mail($to_name, $to_address, $cc, $bcc, $from, $reply, $title, $body){ // 文字コード変換 // 文字コードは"ISO-2022-JP"(S-JIS)に変換する $to = mb_convert_encoding($to_address, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $cc = mb_convert_encoding($cc, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $bcc = mb_convert_encoding($bcc, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $from = mb_convert_encoding($from, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $title = mb_convert_encoding($title, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $body = mb_convert_encoding($body, 'ISO-2022-JP', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); // 件名に日本語が使えるようにエンコードする // (mb_send_mailを使わずにmailを使う為) $title = '=?iso-2022-jp?B?'. base64_encode($title). '?='; // 送信情報の成形 $send_to = mb_encode_mimeheader($to_name, 'ISO-2022-JP'). "<$to>"; $headers = array(); $headers[] = 'From: '. $from; $headers[] = 'Cc: '. $cc; $headers[] = 'Bcc: '. $bcc; $headers[] = 'Reply-To: '. $reply; $headers[] = 'Return-Path: '. $reply; $headers[] = 'Content-Type: text/plain; charset=ISO-2022-JP'; // $headers[] = 'Message-Id: <'. md5(uniqid(microtime())). '@web.analogstd.com>'; $headers[] = 'X-Mailer: PHP/'. phpversion(); $pfrom = '-f $from'; $smresult = mail($send_to, $title, $body, implode("\n", $headers), $pfrom); return $smresult; }

まとめ

以上、簡易的なコメント機能を実装する方法でした。
実装も改造も簡単だと思いますので、あなたのサイトページにも実装してみては如何でしょうか?