WEBページのコメント機能の実装

# はじめに
自作のブログなどでコメント機能を実装したいときに、役立つかもしれません

# クライアント側の実装
htmlの要素である`form`タグを使って、サーバーにデータ送信します
`form`タグの属性の`action`属性に送信先、`method`属性にPOSTかGETを記述します。
今回は送信先を、`comment-send.php`,メソッドはPOSTとします。
```php:main.php
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <title>Comment</title>
</head>

<body>
    <form action="comment-send.php" method ="post">
        <input type="text" name="name" placeholder="お名前"><br>
        <textarea name="text" placeholder="コメントの内容"></textarea>
        <input type="submit" value="コメントを送信する" id="send">
    </form>
</body>
```
`name`属性を`form`タグの中の要素に持たせることで、送信先でデータを受け取ることができます。
`input`タグの`type`を`submit`にすることでボタンを押すことでサーバーに送信できます。
(この状態で押しても意味はないですが)
CSSは適宜デザインしてください。

# サーバー側の実装
## データの取得
クライアント側から送信されたデータはphpのスーパーグローバル変数`$_POST`に格納されています
この変数は連想配列になっていて、keyは`name`属性の値です。
`$_POST['text']`で入力したテキストを取得できます。
```php:comment-send.php
$text = $_POST['text'];
```
## エラーの回避
`comment-send.php`もサーバーに設置するので、設置した状態でこのファイルに直接アクセスするとエラーが起きます。fatal errorではないので、処理は継続されますが、プログラムとしてはよくないので修正します。
エラーの原因は、`$_POST['text']`が存在しないことです。POSTしてないからそうですよね。
これを解決するのが`isset()`関数です。引数に変数の名前を入れ、存在していたら`true`を返します。
`false`になったときのメッセージも設定しておきましょう。
```php:comment-send.php
//エラーメッセージ設定(ヒアドキュメント)
$errorMesseage = <<< EOD
<p>無効なページです 3秒後に戻ります</p>
<script>
    setTimeout(function(){
            window.location.href = "main";
    },3000)
</script>
EOD;

//ポストの受取

$text;
$name;
if(isset($_POST['text'] && isset($_POST['name'])){
    $text = $_POST['text'];
    $name = $_POST['name'];
}
else{
    echo $errorMesseage;
    exit;
}
```
## データの登録
データを取得しただけでは意味がありません。データを何かしらの形で保存しましょう。
今回はMySQLを使います。
まずは、次のSQL文を実行し、テーブルを作成します。テーブル名は`comment`としています。
```
CREATE TABLE `データベース名`.`comment` (`name` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL , `text` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL ,`textNumber` INT NOT NULL , UNIQUE (`textNumber`)) ENGINE = InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;
```

|name|text|textNumber|
|----|----|----------|

というのが作成されるはずです。(カラムは適宜追加してください)
`textNumber`は一番最初のコメントを0としてデータ毎に1,2と増えるようにします。(ユニークキーとしているので、値は被らないように)
### 簡略化

データを取得した状態でデータベースに接続します。今回は`PDO`クラスを使用します。
数回データベースに接続するプログラムを書くので、データベースのパスワードなどを使い回すために、使う情報を別のファイルに記述すると便利です。
```php:key.php
define('DB_PASSWORD','');//MySQLのパスワード
define('DB_USERNAME','');//MySQLのユーザー名
define('DB_HOST','');//ホスト名(Xserverの場合はlocalhost)
define('DB_NAME','');//データベース名
```
このファイルは外部からアクセスできないようにドキュメントルート外に設置しましょう。(Xserverでは`public_html`がドキュメントルート)
今回はディレクトリ構造を以下とします。

┌public_html─┬─comment-send.php
│　　　　　　└─main.php
└key.php

このファイルを`comment-send.php`で読み込みましょう。
読み込むためには`require 'ファイル名'`を使います。ファイル名は相対パスも絶対パスでもいいです。
```php:
require '../key.php';
```
そして変数に定数を代入します。
```php:comment-send.php
//読み込み
require '../key.php';

$dbPass = DB_PASSWORD;
$dbUser = DB_USERNAME;
$dbHost = DB_HOST;
$dbName = DB_NAME;
```

### データベースに接続

phpの機能、`PDO`と`try-catch`(try中にエラーをキャッチしたらcatch中のの処理を行う)を用いて接続します。

```php:comment-send.php
$dsn = "mysql:host={$dbHost};dbname={$dbName}";
try{
    $dbh = new PDO($dsn,$dbUser,$dbPass);//接続
    echo "接続に成功";
    $dbh = null;//切断
} catch(PDOException $e){
    exit('接続に失敗: ' . $e->getMessage());//エラーメッセージ　デバッグ以外はコメントアウト
}
```
### INSERT文

MySQLにデータを追加するときはINSERTを使います。
```
INSERT INTO comment(name, text, textNumber) VALUES ('名前','テキスト','テキスト番号');
```
### SELECT文

textNumberの値は被らないようにしたいので、最大値を取得します。

```
SELECT MAX(textNumber) FROM comment;
```

### SQL文の実行

SQLはqueryメソッドで実行できます。

```php
$sql = "SQL文";
$dbh->query($sql);
```
このqueryメソッドは1行しか実行できません。
### SQLインジェクション対策とXSS対策
データベースにいれる値は動的である必要があります。
ただしデータにいれる値の中に悪意のあるテキストが含まれているかもしれません。
`htmlspecialchars`関数でサニタイズできます。
```php
htmlspecialchars("なにかしらのテキスト", ENT_QUOTES, "UTF-8");
```
`prepare`メソッド,`execute`メソッド,`bindValue`メソッドを使うことでSQLインジェクション対策ができます。
```php:comment-send.php
//textnumberの最大値の取得
$sql =  "SELECT MAX(textNumber) FROM comment";
//sqlの実行
$maxSth = $dbn->query($sql);
$maxResult = $maxSth->fetch(PDO::FETCH_ASSOC);//$maxResult['MAX(textNumber)']に最大値が入っている

if($maxResult['MAX(textNumber)'] === null) $maxResult['MAX(textNumber)'] = 0;//最初のコメント
else $maxResult['MAX(textNumber)']++;//最初以外は+1

//データの登録

//サニタイズ
$safeText = htmlspecialchars($text, ENT_QUOTES, "UTF-8");
$safeName = htmlspecialchars($name, ENT_QUOTES, "UTF-8");

$sql = "INSERT INTO comment(name, text, textNumber) VALUES (:name,:text,:textNumber)";
//sqlの準備
$subscSth = $dbh->prepare($sql);
//値のバインド
$subscSth->bindValue(':name', $safeName);
$subscSth->bindValue(':text', $safeText);
$subscSth->bindValue(':textNumber', $maxResult['MAX(textNumber)']);
//sqlの実行
$subscSth->execute();

```
これでSQL上からは送られたコメントを見ることができるようになりました。

:::note warn
`$maxResult['MAX(textNumber)'] == null`としてしまうと、0がnull扱いとなり常にこの文が実行されます。
:::

# コメントを誰でも見られるようにする
SELECT文で取得してもいいし、自分でAPIを作ってもいい気がします。
今回は素直にSELECT文で取得して表示させます。
JSでGETして、返された値を使って表示させることにします。
## JSでGET
### JS側
JSでGETするには`fetch`を使います。リクエスト先は`getcomment.php`とします。
```js
function GetComment(){
    fetch('getcomment.php')
    .then(response => response.json())
    .then(data =>{
        //dataを使って処理
    })
}
```

:::note info
dataはjson形式ではなく、オブジェクトなので連想配列みたいにアクセスできます。
json()がjsonを読み取って変換してくれます。(とてもgood)
:::

### PHP側

パラメータなどは受け取らないので`$_GET`とかは必要ありません
JSに返すときは、json形式にして`echo`などの出力をすると返すことが来ます

```php
$data['test'] = "テスト"
echo json_encode($data);//{“test” : “テスト”}が返される。
```
:::note warn
`echo`のあとすぐに、`exit`をすると返されないことがある。
:::

## 実装

### PHP側
`getcomment.php`はpublic_html直下にあるとします。

```php:getcomment.php
<?php
require '../key.php';

$dbPass = DB_PASSWORD;
$dbUser = DB_USERNAME;
$dbHost = DB_HOST;
$dbName = DB_NAME;

$dsn = "mysql:host={$dbHost};dbname={$dbName}";
try{
    $dbh = new PDO($dsn,$dbUser,$dbPass);//接続
    $sql = "SELECT * FROM comment ORDER BY textNumber DESC limit 50"; 
    $dataSth = $dbh->query($sql);
    $dataResult = $dataSth->fetchAll(PDO::FETCH_ASSOC);
    $dbh = null;//切断
    echo json_encode($dataResult);//出力
} catch(PDOException $e){
    exit();
}
?>
```
SQL文の`ORDER BY textNumber DESC`でtextNumberの降順で抽出し、`limit 50`で最大50行抽出としています。
`fetchAll`は抽出されたすべてのデータを返すメソッドです。(`fetch`は1行のみ)

### JS側

コメントデータを取得→データに基づいてHTML要素を作成→表示させる
という処理にしています。

```js:main.js
function GetComment(){
    const form = document.getElementsByTagName('form');
    fetch('getcomment.php')
    .then(response => response.json())
    .then(CommentDate =>{
        CommentDate.forEach(function(data){
            //form要素の下に挿入
            form[0].insertAdjacentElement("afterend", CreateComment(data));
        })
    })
    
}

function CreateComment(Data){
    //要素を作成
    const newDiv = document.createElement("div");
    const newCommentArea = document.createElement('textarea');
    const username = document.createElement('label');
    //取得したデータを代入
    username.innerText = Data['name'];
    newCommentArea.value = Data['text'];
    newCommentArea.readOnly = true;//テキストエリアを読み込み専用に
    //divの子要素として追加
    newDiv.appendChild(username);
    newDiv.appendChild(newCommentArea);
    //クラスを付与
    newDiv.setAttribute("class","comments");
    return newDiv;
}
//すべてのDOMを読み込んだときに起動
window.onload = GetComment;
```
これでコメントの投稿と表示ができるようになりました。

:::note warn
`.then`で渡したあとのデータは別の関数などに渡さずに、内部で処理
:::

# reCAPTCHAの実装
ついでに、スパム対策としてreCAPTCHAを実装します。
最新であるreCAPTCHA v3は、チェックボックスはなく、スコア(0.0~1.0)に基づいて、判定する仕組みになっています。
今回は馴染みあるチェックボックスで判定するv2を採用します。

## reCAPTCHAに登録
以下のサイトにアクセスし、サイトを登録します。
`reCAPTCHAタイプ`はv2選択してください。
```
https://www.google.com/recaptcha/admin/create
```

そうするとサイトキーとシークレットキーが発行されます。
シークレットキーは公開しないようにしてください。
この2つのキーもファイルから読み込むようにします。
以下を`key.php`に追加します。
```php
define('V2_SITEKEY','サイトキー');
define('V2_SECRETKEY','シークレットキー');
```
## 導入方法
### クライアント側
`form`タグの中に

```html
<div class="g-recaptcha" data-sitekey="サイトキー" data-callback="Callback" data-expired-callback="expiredCallback"></div>
```
を追加(サイトキーは直接書いてもいいし、ファイルを読み込んで`echo`で出力するでもいいです)
`body`の最後に
```html
<script>
    var Callback = function(res){
        //reCAPTCHA承認時の処理
    }
    var expiredCallback = function(){
        //承認失敗時の処理
    }
</script>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
```
を追加

`submit`時にレスポンストークンも一緒にPOSTされます。

### サーバー側

レスポンストークンは`$_POST['g-recaptcha-response']`に格納されています。このトークンが有効であるかどうかをAPIを使って判定します。
PHPでAPIを叩く方法(サーバーにリクエストを送信する)は複数ありますが、今回は`curl`関数を使います。
メソッドはPOSTです。GETでもいけるみたいです。
```php:comment-send.php
$secretKey = V2_SECRETKEY;
$url = 'https://www.google.com/recaptcha/api/siteverify';//APIのURL
$params = "secret={$secretKey}&response={$recaptcha}";//送信する情報
$header = ['Content-Type: application/x-www-form-urlencoded'];//ヘッダー情報
$ch = curl_init();//初期化
//転送設定
curl_setopt($ch, CURLOPT_POST, true);//POST設定をtrueに
curl_setopt($ch, CURLOPT_URL, $url);//リクエスト先指定
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);//リクエストボディ
curl_setopt($ch, CURLOPT_RETURNTRANSFER,true);//返り値を文字列に
curl_setopt($ch, CURLOPT_HTTPHEADER, $header);//ヘッダー指定
//実行して格納
$result = json_decode(curl_exec($ch));
//閉じる
curl_close($ch);
if(!$result->success) exit;//承認失敗時
```
reCAPTCHAの返り値はJSON形式なので、`json_decode`で変換しています。`json_decode`の第二引数を`true`に指定すると、連想配列として変換することもできます。
`$result->success`には`true`か`false`が入っています。

# 終わり
だいぶ内容を詰め込みました。書いていて楽しかったです。サンプルコードをすべて載せておきます。
<details><summary>main.php</summary>

```php:main.php
<?php

//キーファイルの読み込み
require '../key.php';
$sitekey = V2_SITEKEY;
?>
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <title>Comment</title>
    <link rel="stylesheet" href="main.css?<?php echo date('YmdHis', filemtime('main.css')); ?>">
</head>
<body>
    <form action="comment-send" id="form" method ="post">
        <input type="text" name="name" placeholder="お名前"><br>
        <textarea id="text" name="text" placeholder="コメントの内容"></textarea>
        <div class="g-recaptcha" id="recaptcha" data-sitekey="<?php echo $sitekey;?>" data-callback="Callback" data-expired-callback="expiredCallback"></div>
        <input type="submit" value="コメントを送信する" id="send" disabled>
    </form>
    <script> 
        var Callback = function(res) { //コールバック関数の定義
            document.getElementById("send").disabled = false;
        };

        var expiredCallback = function() { //コールバック関数の定義
            document.getElementById("send").disabled = true;
        };
        
    </script>
    <script src="https://www.google.com/recaptcha/api.js" async defer></script>
    <script type="text/javascript" src="main.js?<?php echo date('YmdHis', filemtime('main.js')); ?>"></script>
</body>
```
</details>
<details><summary>main.css</summary>

```css:main.css
textarea {
    resize: none;
    width:300px;
    height:200px;
    font-size: 20px;
}
```
</details>
</details>
<details><summary>main.js</summary>

```js:main.js
function GetComment(){
    const form = document.getElementsByTagName('form');
    fetch('getcomment.php')
    .then(response => response.json())
    .then(CommentDate =>{
        CommentDate.forEach(function(data){
            //form要素の下に挿入
            form[0].insertAdjacentElement("afterend", CreateComment(data));
        })
    })
    
}

function CreateComment(Data){
    //要素を作成
    const newDiv = document.createElement("div");
    const newCommentArea = document.createElement('textarea');
    const username = document.createElement('label');
    //取得したデータを代入
    username.innerText = Data['name'];
    newCommentArea.value = Data['text'];
    newCommentArea.readOnly = true;
    //divの子要素として追加
    newDiv.appendChild(username);
    newDiv.appendChild(newCommentArea);
    newDiv.setAttribute("class","comments");
    return newDiv;
}
//すべてのDOMを読み込んだときに起動
window.onload = GetComment;
```
</details>
</details>
<details><summary>getcomment.php</summary>

```php:getcomment.php
<?php
require '../key.php';

$dbPass = DB_PASSWORD;
$dbUser = DB_USERNAME;
$dbHost = DB_HOST;
$dbName = DB_NAME;

$dsn = "mysql:host={$dbHost};dbname={$dbName}";
try{
    $dbh = new PDO($dsn,$dbUser,$dbPass);//接続
    $sql = "SELECT * FROM comment ORDER BY textNumber DESC limit 50"; 
    $dataSth = $dbh->query($sql);//クエリを実行
    $dataResult = $dataSth->fetchAll(PDO::FETCH_ASSOC);
    $dbh = null;//切断
    echo json_encode($dataResult);//出力
} catch(PDOException $e){
    exit();
}
?>
```
</details>
</details>
<details><summary>comment-send.php</summary>

```php:comment-send.php
<?php
//キーファイル読み込み
require '../../key.php';
$secretKey = V2_SECRETKEY;
$dbPass = DB_PASSWORD;
$dbUser = DB_USERNAME;
$dbHost = DB_HOST;
$dbName = DB_NAME;
//エラーメッセージ設定
$errorMesseage = <<< EOD
<p>無効なページです 3秒後に戻ります</p>
<script>
    setTimeout(function(){
            window.location.href = "main";
    },3000)
</script>
EOD;
//成功メッセージ設定
$ssuccessrMesseage = <<< EOD
<p>コメントに成功しました 3秒後に戻ります</p>
<script>
    setTimeout(function(){
            window.location.href = "main";
    },3000)
</script>
EOD;
//ポストの受取

$text;
$recaptcha;
$name;
if(isset($_POST['text']) && isset($_POST['g-recaptcha-response']) && isset($_POST['name'])){
    $text = $_POST['text'];
    $recaptcha = $_POST['g-recaptcha-response'];
    $name = $_POST['name'];
}
else{
    echo $errorMesseage;
    exit;
}

//reCAPTCHA API起動
$url = 'https://www.google.com/recaptcha/api/siteverify';//APIのURL
$params = "secret={$secretKey}&response={$recaptcha}";//送信する情報
$header = ['Content-Type: application/x-www-form-urlencoded'];//ヘッダー情報
$ch = curl_init();//初期化
//転送設定
curl_setopt($ch, CURLOPT_POST, true);//POST設定をtrueに
curl_setopt($ch, CURLOPT_URL, $url);//リクエスト先指定
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);//リクエストボディ
curl_setopt($ch, CURLOPT_RETURNTRANSFER,true);//返り値を文字列に
curl_setopt($ch, CURLOPT_HTTPHEADER, $header);//ヘッダー指定
//実行して格納
$result = json_decode(curl_exec($ch));
//閉じる
curl_close($ch);

if(!$result->success){
    echo $errorMesseage;//トークンが不正なとき
    exit;
};


$dsn = "mysql:host={$dbHost};dbname={$dbName}";
try{
    $dbh = new PDO($dsn,$dbUser,$dbPass);//接続
    $sql =  "SELECT MAX(textNumber) FROM comment";
    //sqlの実行
    $maxSth = $dbh->query($sql);
    $maxResult = $maxSth->fetch(PDO::FETCH_ASSOC);//$maxResult['MAX(textNumber)']に最大値が入っている
    
    if($maxResult['MAX(textNumber)'] === null) $maxResult['MAX(textNumber)'] = 0;//最初のコメント
    else $maxResult['MAX(textNumber)']++;//最初以外は+1
    
    //データの登録
    
    //サニタイズ
    $safeText = htmlspecialchars($text, ENT_QUOTES, "UTF-8");
    $safeName = htmlspecialchars($name, ENT_QUOTES, "UTF-8");
    
    $sql = "INSERT INTO comment(name, text, textNumber) VALUES (:name,:text,:textNumber)";
    //sqlの準備
    $subscSth = $dbh->prepare($sql);
    //値のバインド
    $subscSth->bindValue(':name', $safeName);
    $subscSth->bindValue(':text', $safeText);
    $subscSth->bindValue(':textNumber', $maxResult['MAX(textNumber)']);
    //sqlの実行
    $subscSth->execute();
    echo $ssuccessrMesseage;//コメントできたとき
    $dbh = null;//切断
} catch(PDOException $e){
    exit();
}
?>
```
</details>
</details>
<details><summary>key.php</summary>

```php:key.php
<?php
//データベース用
define('DB_PASSWORD','');//MySQLのパスワード
define('DB_USERNAME','');//MySQLのユーザー名
define('DB_HOST','');//ホスト名(Xserverの場合はlocalhost)
define('DB_NAME','');//データベース名

//reCAPTCHA用
define('V2_SITEKEY','サイトキー');
define('V2_SECRETKEY','シークレットキー');
?>
```
</details>