初心者がPHPで掲示板を作ってみた

# はじめに

プログラミング学習のアウトプットとして掲示板の制作しました。
この記事では掲示板の概要や制作過程について説明します。
ソースコードはこちらになっております。
[github:yukisugikawa/bulletin-board](https://github.com/yukisugikawa/bulletin-board.git)
![circleanimationmuvie](https://im3.ezgif.com/tmp/ezgif-3-ac148cf59139.gif)

# 目的
掲示板の作成の過程で基本的なweb制作の技術を学ぶ。
就職活動に向けてのポートフォリオ作り。

# 開発環境
使用言語/HTML5/CSS3/PHP7

DB/MAMP/MYSQL

開発環境/macOS Catalina 10.15.3

#主な機能

###ユーザー管理機能
・ユーザー登録機能
・ユーザーログイン機能

###掲示板機能
・投稿機能
・コメント機能
・編集、削除機能

#機能の説明
簡単にどういったコードで実装したか説明します。
見やすいように要点のみを書きました。

#1.データベース設計
・threadsテーブルのboard_idはboardsテーブルと紐付けるために追加しました。

DB: bulletin-boards

boardsテーブル
<img width="868" alt="スクリーンショット 2020-03-06 9.50.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453557/2de0b963-9640-5186-973d-69fc69669ae1.png">

threadsテーブル
<img width="796" alt="スクリーンショット 2020-03-06 9.49.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453557/ae254cb1-522c-7202-a3f1-c50b1640ed0c.png">

users_registrationsテーブル
<img width="782" alt="スクリーンショット 2020-03-06 9.48.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453557/efecf213-575b-8076-cf21-51c8447554af.png">

##データベース接続関数
config.php :　DB設定の定数
db.php :  DB接続のクラス
・DBの接続を`construct`ですることでインスタンス化する時に自動的にDBに接続してくれるようにしています。

<details><summary>config.php</summary><div>

```config.php
<?php
define('DNS', 'mysql:host=localhost;dbname=bulletin_boards;charset=utf8mb4');
define('DB_USER', 'root');
define('DB_PASS', 'root');
```
</div></details>


<details><summary>db.php</summary><div>

```db.php
<?php
class DB
{

  private $pdo = null;

  public function __construct()
  {
    $option = [
      PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
      PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
    ];
    try{
      $this->pdo = new PDO(constant('DNS'),constant('DB_USER'),constant('DB_PASS'),$option);
    }catch(PDOException $e){
    echo $e->getMessage();
    }
  }

  public function select($sql, array $params = [])
  {
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
  }

  public function insert($sql, array $params = [])
  {
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt;
  }

  public function update($sql, array $params = [])
  {
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt;
  }

  public function delete($sql, array $params = [])
  {
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt;
  }
}
```
</div></details>


#2.会員登録機能
index.php : 新規登録画面
・入力内容登録後、ログイン画面に移動。
↓

login.php : ログイン画面
・入力内容がDBにあるなら、ログイン成功。掲示板へ移動。

##index.php
・POSTされたら、`filter_input`を使い入力値を取得。
・メールアドレスの重複とバリデーションクラスでエラーがなければ、入力内容を登録。

<details><summary>index.php</summary><div>

```index.php
if(filter_input(INPUT_SERVER,'REQUEST_METHOD') === 'POST'){
  $name = filter_input(INPUT_POST, 'name');
  $mail = filter_input(INPUT_POST, 'mail');
  $pass = filter_input(INPUT_POST, 'pass');
  $pass_conf = filter_input(INPUT_POST, 'pass_conf');
  $token = filter_input(INPUT_POST, 'token');

  $vdt = new Validation;
  $vdt->check_max($name,'名前',10);
  $vdt->check_type($mail,'メールアドレス');
  $vdt->check_type($pass,'パスワード');
  $vdt->check_match($pass,$pass_conf,'確認用パスワード');
  $vdt->check_type($token,'トークン');
  $error = $vdt->get_error();

  if(empty($error)){

    $db = new DB;
    $sql = 'INSERT INTO users_registrations (users_registrations_id,name,mail,pass) VALUES (null,?,?,?)';
    $params[] = $name;
    $params[] = $mail;
    $params[] = password_hash($pass,PASSWORD_DEFAULT);
    $success = $db->insert($sql,$params);
  }
}
```

</div></details>


##login.php
・SELECTで取得したものと入力情報が合っているか確認。
名前は掲示板でも使うので`$_SESSION['name']`に代入。


<details><summary>login.php</summary><div>

```login.php
  if(empty($error)){

    $db = new DB;
    $sql = 'SELECT * FROM users_registrations WHERE name=? AND mail=?';
    $params[] = $name;
    $params[] = $mail;
    $rows = $db->select($sql,$params);
    foreach ($rows as $row) {
      $hash = $row['pass'];
      $name = $row['name'];
    }
    if(password_verify($pass,$hash)){
      unset($_SESSION['name']);
      session_regenerate_id(true);
      $_SESSION['name'] = $name;
      header('Location:boards.php');
      exit;
    }else{
      $error[] = 'パスワードが一致しません。';
    }
  }
```
</div></details>


#3.掲示板機能
boards.php : 掲示板
・投稿内容を表示(投稿者の名前、投稿時間、タイトル)
・タイトルに対応するコメント欄に飛ぶリンクを設置。
↓

threads.php : コメント欄
・投稿内容を表示(投稿者の名前、投稿時間、コメント)。

##boards.php
・掲示板とコメント欄を紐付けるためのboard_idをthreadsテーブルに追加したいので、aタグにboardsテーブルのboards_idを付与する。

<details><summary>boards.php</summary><div>

```boards.php
<?php
$name = $_SESSION['name'];

if(empty($error)){
  $db = new DB;
  $sql = 'INSERT INTO boards(boards_id,title,created_at,name) VALUES(null,?,?,?)';
  $params[] = $title;
  $params[] = $created_at;
  $params[] = $name;
  $db->insert($sql,$params);

  header('Location:boards.php');
  exit;
}

$db = new DB;
$sql = 'SELECT * FROM boards ORDER BY boards_id DESC';
$rows = $db->select($sql);
$count = count($rows);

?>

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>掲示板</title>
  </head>
  <body>
    <?php foreach($rows as $row): ?>
      <div class="flexbox">
        <div class="posts">
          <a href='threads.php?boards_id=<?php echo $row['boards_id']; ?>'>
            <?php echo h($row['name']); ?>
            <?php echo h($row['created_at']); ?><br>
            <?php echo h($row['title']); ?>
          </a>
        </div>
        <div class="button">
          <form action="change.php" method="get">
            <button type="submit" name="boards_id" value="<?php echo h($row['boards_id']); ?>">編集</button>
　　　　　　　<input type="hidden" name="title" value="<?php echo h($row['title']); ?>">
          </form>
          <form action="delete.php" method="get">
            <button type="submit" name="boards_id" value="<?php echo h($row['boards_id']); ?>">削除</button>
　　　　　　　<input type="hidden" name="title" value="<?php echo h($row['title']); ?>">
          </form>
        </div>
      </div>
    <?php endforeach; ?>
  </body>
</html>
```

</div></details>


##threads.php
・bords.phpからのboards_idをGETで取得。
・掲示板のタイトルを消した時にコメントだけが残ったりしないように`INNER JOIN threads ON boards.boards_id = threads.board_id`で２つのテーブルを内部結合させます。

<details><summary>threads.php</summary><div>

```threads.php
$name = $_SESSION['name'];
$board_id = filter_input(INPUT_GET, 'boards_id');

if(empty($error)){
  $db = new DB;
  $sql = 'INSERT INTO threads(threads_id,board_id,comment,commented_at,name)VALUES (null,?,?,?,?)';
  $params[] = $board_id;
  $params[] = $comment;
  $params[] = $commented_at;
  $params[] = $name;
  $rows = $db->insert($sql,$params);

  header("Location:threads.php?boards_id=$board_id");
  exit;
}

$db = new DB;
$sql = 'SELECT * FROM boards
   INNER JOIN thrads ON boards.boards_id = threads.board_id
   WHERE board_id=?
   ORDER BY threads.threads_id DESC';
$params = [];
$params[] = $board_id;
$rows = $db->select($sql,$params);
$count = count($rows);
```

</div></details>

#3.変更、削除機能
change.php : 編集機能
delete.php : 削除機能
・boards.php、threads.php両方からアクセス可能。
・タイトル、コメント一つ一つに編集、削除ボタンを付けました。ボタンのidは変更、削除用で付与しました。
・編集、削除する項目を表示しました。

##change.php
・どこからアクセスが来たかをidで判別しています。そのidを元に変更をしています。
・board_idは飛んできたコメント欄に戻れるように受け取っています。

<details><summary>change.php</summary><div>

```change.php
<?php
$boards_id = filter_input(INPUT_GET, 'boards_id');
$threads_id = filter_input(INPUT_GET, 'threads_id');
$board_id = filter_input(INPUT_GET, 'board_id');
$title = filter_input(INPUT_GET, 'title');
$comment = filter_input(INPUT_GET, 'comment');

if(filter_input(INPUT_SERVER,'REQUEST_METHOD') === 'POST'){

  //POST受け取り、バリデーションチェック省略

  $db = new DB;
  $sql = 'SELECT pass FROM users_registrations';
  $rows = $db->select($sql);
  foreach ($rows as $row) {
    $user_pass = $row['pass'];
  }

  if(empty($error)){
    if(isset($boards_id) && password_verify($change_pass,$user_pass)){
      $db = new DB;
      $sql = 'UPDATE boards SET title=?, created_at=? WHERE boards_id=?';
      $params = [];
      $params[] = $new_title;
      $params[] = $created_at;
      $params[] = $boards_id;
      $success_change_boards = $db->update($sql,$params);
    }elseif(isset($threads_id) && password_verify($change_pass,$user_pass)){
      $db = new DB;
      $sql = 'UPDATE threads SET comment=?, commented_at=? WHERE threads_id=?';
      $params = [];
      $params[] = $new_comment;
      $params[] = $created_at;
      $params[] = $threads_id;
      $success_change_threads = $db->update($sql,$params);
    }else{
      $error[] = 'パスワードが一致しません。';
    }
  }
}
?>
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>変更ページ</title>
  </head>
  <body>
　　　<?php if(isset($success_change_boards) && $success_change_boards): ?>
	　　<p>パスワードが一致し、投稿内容を編集しました。</p>
	　　<p><a href="boards.php">掲示板に戻る。</a></p>
　　　<?php elseif(isset($success_change_threads) && $success_change_threads): ?>
		<p>パスワードが一致し、投稿内容を編集しました。</p>
		<p><a href="threads.php?boards_id=<?php echo h($board_id); ?>">コメント欄に戻る</a></p>
    　<?php else: ?>

    　<?php if(isset($board_id)): ?>
      　　<p>『<?php echo h($comment); ?>』</p>
      <!-- パスワード、コメントのform -->

    　<?php else: ?>
      　　<p>『<?php echo h($title); ?>』</p>
　　　　<!-- パスワード、タイトルのform -->

 　　　<?php endif; ?>
  </body>
</html>
```

</div></details>

##delete.php
・削除ページも変更ページとよく似た作りです。
・どこからアクセスが来たかをidで判別、そのidを元に削除をしています。
・board_idは飛んできたコメント欄に戻れるように受け取っています。
・SESSIONは変更画面でも何を変更するのかを表示するために受け取っています。

<details><summary>delete.php</summary><div>

```delete.php
$boards_id = filter_input(INPUT_GET, 'boards_id');
$threads_id = filter_input(INPUT_GET, 'threads_id');
$board_id = filter_input(INPUT_GET, 'board_id');
$title = filter_input(INPUT_GET, 'title');
$comment = filter_input(INPUT_GET, 'comment');

if(filter_input(INPUT_SERVER,'REQUEST_METHOD') === 'POST'){

  //POST受け取り、バリデーションチェック省略

  $db = new DB;
  $sql = 'SELECT pass FROM users_registrations';
  $rows = $db->select($sql);
  foreach ($rows as $row) {
    $user_pass = $row['pass'];
  }

  if(empty($error)){
    if(isset($boards_id) && password_verify($delete_pass,$user_pass)){
      $sql = 'DELETE FROM boards WHERE boards_id=?';
      $params[] = $boards_id;
      $success_delete_boards = $db->delete($sql,$params);
    }elseif(isset($threads_id) && password_verify($delete_pass,$user_pass)){
      $sql = 'DELETE FROM threads WHERE threads_id=?';
      $params[] = $threads_id;
      $success_delete_threads = $db->delete($sql,$params);
    }else{
    $error[] = 'パスワードが一致しません。';
    }
  }
}
```

</div></details>


#4.共通関数
common.php : 共通関数
・登録、ログインページでログインしてたら、掲示板へ。掲示板、コメント欄でSESSIONが切れた場合はログイン画面へ飛ばされます。
・入力値がバリデーションに引っかかるとエラーの配列に格納されます。
 `$error = $vdt->get_error();`でエラーを取得します。

<details><summary>common.php</summary><div>

```common.php
<?php
require 'config.php';

//エスケープ処理
function h($s)
{
    return htmlspecialchars($s,ENT_QUOTES,'UTF-8');
}

// ログインしてたら掲示板へ
function require_logined_session()
{
  @session_start();
  if(isset($_SESSION['name'])){
    header('Location: boards.php');
    exit;
  }
}

// ログインしていなければログイン画面へ
function require_unlogined_session()
{
  //自動ログアウト
  ini_set('session.gc_maxlifetime', 3000); //30分間SESSIONを保持する
  ini_set('session.gc_probability', 1);
  ini_set('session.gc_divisor', 1); //SESSIONを消す確率を1/1（１００％）にする
  @session_start();
  if(!isset($_SESSION['name'])){
    header('Location: login.php');
    exit;
  }
}

// トークン生成
function generate_token()
{
  return hash('sha256', session_id());
}

//バリデーションクラス
class Validation
{

  private $error = [];

  //プロパティに格納されたエラーをgetする
  public function get_error()
  {
    return $this->error;
  }

  //未入力チェック
  private function blank_check($value='', $colName='')
  {
    if($value === ''){
      $this->error[] = $colName . 'は入力必須です。';
      return true;
    }
    return false;
  }

  // 最大文字数チェック
  public function check_max($value,$colName,$max)
  {
    $this->blank_check($value,$colName);
    if(mb_strlen($value) > $max){
    $this->error[] = $colName . 'は'. $max . '文字以下で入力してください。';
    }
    return true;
  }

  // 最小文字数チェック
  public function check_min($value,$colName,$min)
  {
    $this->blank_check($value,$colName);
    if(mb_strlen($value) < $min){
    $this->error[] = $colName . 'は'. $min . '文字以上で入力してください。';
    }
    return true;
  }

  //一致チェック
  public function check_match($value,$conf_value,$colName)
  {
    $this->blank_check($value,$colName);
    if($value !== $conf_value){
      $this->error[] = $colName . 'が一致しません。';
    }
    return true;
  }

  // 形式チェック
  public function check_type($value,$type)
  {
    switch($type){

      case "メールアドレス":
        if($this->blank_check($value,'メールアドレス')) return false;
        if(!filter_var($value,FILTER_VALIDATE_EMAIL)){
          $this->error[] = 'メールアドレスは正しい形式で入力してください。';
        }
        break;

      case "パスワード":
        if($this->blank_check($value,'パスワード')) return false;
        if(!preg_match('/\A(?=.*?[a-z])(?=.*?\d)[a-z\d]{6,10}+\z/i',$value)){
            $this->error[] = 'パスワードは半角英数字をそれぞれ1種類以上含む5文字以上10文字以下で入力してください。';
        }
        break;

        case "トークン":
          if(!$value === generate_token()){
            $this->error[] = 'トークンが一致しません。';
          }
          break;

      default:
        break;
    }
  }
}
```

</div></details>


#5.セキュリティー対策

####1.SQLインジェクション
・プレスホルダー、プリペアドステートメントの使用

####2.セッションハイジャック
・`session_regenerate_id(true)`でセッションIDを更新し、セッション固定攻撃を回避。

####3.クロスサイトスクリプティング（XSS)
・HTMLに出力する時は`htmlspecialchars関数`でエスケープする。

####4.クロスサイトリクエストフォージェリ（CSRF)
・全てのPOSTするformにトークンを埋め込み、送信したトークンとハッシュ値が一致するかを確認。

```php:トークンの発行、検証、埋め込み
function generate_token()
{
  return hash('sha256', session_id());
}

if(!$value === generate_token()){
  $this->error[] = 'トークンが一致しません。';
}

<input type="hidden" name="token" value="<?php echo h(generate_token()); ?>">
```

#反省点

###1.何度も調べ直す
・調べて分かったことを理解したことを求めておらず、無駄に調べることがあった。
→調べて理解したことは、Qiitaなどにまとめる。自分の言葉でまとめれるとその分理解度も深まる。

###2.アウトプットが少ない
・インプット量の問題もありますが、アウトプットが少ないと思いました。また、人に見られないとこにアウトプット（メモ帳など）するのは他の人からの評価を受けないし、自分のモチベーションにもつながらないと思いました（自分の投稿にいいねされたのが嬉しかった）。

#感想
学習の過程で、プログラムを書きたくないなと思う時もありましたが、自分でものを作り終えたときの達成感はこれまで頑張ってきて良かったな。と思いました。
これからもっとレベルアップしていきたいので向上心を持ってがんばりたいです。
最後まで見ていただきありがとうございました。