【連載付録】全コード公開!掲示板アプリの「完全版」ソースコードと徹底解説

LAMP講座

【連載付録】エラーで諦めかけたあなたへ贈る「正解コード」

未経験からWebアプリ開発を目指す「AlmaLinux 9とLAMP環境で作る!Webアプリ開発完全ロードマップ(全12回)」、完走おめでとうございます!

全12回の連載を通じて、サーバー構築からデータベース連携、そして独自ドメインでの公開までを駆け抜けました。
しかし、プログラミングの世界では「記事の通りに書いたつもりなのに動かない!」というトラブルがつきものです。
特に後半の「セキュリティ対策(CSRFトークン)」などは、複数のファイルを修正する必要があり、混乱してしまった方もいるかもしれません。

そこで今回は、連載の「付録(ボーナストラック)」として、セキュリティ対策もバグ修正もすべて完了した「掲示板アプリの最終完成版ソースコード」を公開します。

これをコピー&ペーストすれば、あなたのサーバーで掲示板が確実に動作します。
答え合わせとして、あるいは自分専用のカスタマイズの土台として活用してください。

コウ君

先生、ありがとうございます!
実は第11回の「宿題(編集画面へのCSRF対策)」でちょっと自信がなかったんです。
このコードを使えば完璧ってことですね?

リナックス先生

その通りよ。
今回はただコードを貼るだけじゃなく、「なぜその記述が必要なのか」という技術的な解説も詳しく書いたわ。
コードを読み解く力(リーディングスキル)を養うための教材としても使ってね。

事前準備:サーバーとデータベースの確認

コードを貼り付ける前に、サーバーのフォルダ構成とデータベースが正しく準備されているか、最後の確認を行いましょう。

1. ディレクトリと権限の設定

画像を保存するためのフォルダ images が存在し、Webサーバー(apache)が書き込める権限になっていることが必須です。

# ディレクトリ作成(-p は既にあったら無視するオプション)
mkdir -p /var/www/html/bbs/images

# 所有者をapacheに変更
chown -R apache:apache /var/www/html/bbs

# パーミッション設定(755: 所有者は書き込み可、他は読むだけ)
chmod 755 /var/www/html/bbs
chmod 755 /var/www/html/bbs/images

2. データベースの作成

MariaDBにログインし、以下のSQLが実行済みであることを確認してください。
(まだ作っていない場合は、以下のSQLをそのまま流し込めばOKです)

CREATE DATABASE IF NOT EXISTS app_db;
USE app_db;

CREATE TABLE IF NOT EXISTS posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    comment TEXT NOT NULL,
    image_path VARCHAR(255),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

DB接続ユーザー情報(今回のコードで使用):

  • データベース名: app_db
  • ユーザー名: app_user
  • パスワード: password123

ファイル1:style.css(デザイン)

まずは見た目を整えるスタイルシートです。
/var/www/html/bbs/style.css として保存してください。

/* 全体のリセットと基本設定 */
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", sans-serif;
    background-color: #f0f2f5;
    color: #333;
    line-height: 1.6;
}

/* ヘッダー */
header {
    background-color: #4CAF50;
    color: white;
    padding: 1rem;
    text-align: center;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

/* メインコンテンツ */
main {
    max-width: 600px;
    margin: 20px auto;
    padding: 0 10px;
}

/* カード型デザインの共通クラス */
.form-container, .post {
    background: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
    margin-bottom: 20px;
}

/* フォーム部品 */
.input-group {
    margin-bottom: 15px;
}

label {
    display: block;
    margin-bottom: 5px;
    font-weight: bold;
    font-size: 0.9rem;
}

input[type="text"], textarea {
    width: 100%;
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-size: 1rem;
}

textarea {
    height: 100px;
    resize: vertical;
}

.submit-btn {
    width: 100%;
    background-color: #4CAF50;
    color: white;
    border: none;
    padding: 12px;
    font-size: 1.1rem;
    font-weight: bold;
    border-radius: 4px;
    cursor: pointer;
    transition: background-color 0.3s;
}

.submit-btn:hover {
    background-color: #45a049;
}

/* タイムライン(記事) */
.post-header {
    display: flex;
    justify-content: space-between;
    margin-bottom: 10px;
    border-bottom: 1px solid #eee;
    padding-bottom: 5px;
}

.post-name { font-weight: bold; color: #4CAF50; }
.post-date { font-size: 0.8rem; color: #888; }
.post-image { max-width: 100%; border-radius: 4px; margin-top: 10px; border: 1px solid #eee; }

/* 編集・削除ボタンエリア */
.post-actions {
    margin-top: 15px;
    text-align: right;
    font-size: 0.9rem;
}

.post-actions a {
    color: #0066cc;
    text-decoration: none;
    margin-right: 15px;
}

.post-actions button {
    color: #cc0000;
    background: none;
    border: none;
    cursor: pointer;
    text-decoration: underline;
    font-size: 0.9rem;
}

/* メッセージ表示エリア */
.alert {
    padding: 15px;
    margin-bottom: 20px;
    border-radius: 4px;
    font-weight: bold;
}
.success { background-color: #e6fffa; color: #005500; border: 1px solid #005500; }
.error { background-color: #ffe6e6; color: #aa0000; border: 1px solid #aa0000; }

/* フッター */
footer {
    text-align: center;
    padding: 20px;
    color: #888;
    font-size: 0.8rem;
}

ファイル2:index.php(メイン画面)

アプリの顔となるメインファイルです。
投稿の受付、画像保存、一覧表示、CSRFチェックの全てが詰まっています。
/var/www/html/bbs/index.php として保存してください。

<?php
session_start(); // セッション開始(CSRF対策に必須)

// エラー表示設定(公開時は 0 にすること)
ini_set('display_errors', 1);
error_reporting(E_ALL);

// DB接続設定
$dsn = 'mysql:dbname=app_db;host=localhost;charset=utf8mb4';
$user = 'app_user';
$password = 'password123';

// CSRFトークンの生成(セッションにない場合のみ生成)
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];

$success_message = null;
$error_message = null;

// ▼ 投稿機能(POSTリクエスト受信時)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    // 1. CSRFトークンチェック(セキュリティの要)
    if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
        die('不正なリクエストです。(トークン不一致)');
    }

    $name = $_POST['name'] ?? '';
    $comment = $_POST['comment'] ?? '';
    $image_filename = null;

    // 2. 入力チェック(バリデーション)
    if (empty($name) || empty($comment)) {
        $error_message = '名前とコメントは必須です。';
    } else {
        // 3. 画像アップロード処理
        if (!empty($_FILES['image']['name']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
            $original_name = $_FILES['image']['name'];
            $extension = pathinfo($original_name, PATHINFO_EXTENSION);
            
            // ファイル名のユニーク化(日時+ランダム文字列)
            $new_filename = date('YmdHis') . '_' . uniqid() . '.' . $extension;
            $upload_dir = '/var/www/html/bbs/images/';
            
            // 一時フォルダから本番フォルダへ移動
            if (move_uploaded_file($_FILES['image']['tmp_name'], $upload_dir . $new_filename)) {
                $image_filename = $new_filename;
            } else {
                $error_message = '画像のアップロードに失敗しました。(権限を確認してください)';
            }
        }

        // 4. データベース保存処理
        if (empty($error_message)) {
            try {
                $pdo = new PDO($dsn, $user, $password);
                $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
                
                // プリペアドステートメントでSQLインジェクション対策
                $sql = "INSERT INTO posts (name, comment, image_path) VALUES (:name, :comment, :image)";
                $stmt = $pdo->prepare($sql);
                $stmt->bindValue(':name', $name, PDO::PARAM_STR);
                $stmt->bindValue(':comment', $comment, PDO::PARAM_STR);
                $stmt->bindValue(':image', $image_filename, $image_filename ? PDO::PARAM_STR : PDO::PARAM_NULL);
                
                $stmt->execute();
                $success_message = '投稿しました!';
                
            } catch (PDOException $e) {
                $error_message = 'DBエラー: ' . $e->getMessage();
            }
        }
    }
}

// ▼ 一覧表示機能(常に実行)
$posts = [];
try {
    $pdo = new PDO($dsn, $user, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    
    // 新しい順に全件取得
    $sql = "SELECT * FROM posts ORDER BY created_at DESC";
    $stmt = $pdo->query($sql);
    $posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
    
} catch (PDOException $e) {
    $error_message = 'データ取得エラー: ' . $e->getMessage();
}
?>

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ひとこと掲示板</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>ひとこと掲示板</h1>
    </header>

    <main>
        <!-- メッセージ表示 -->
        <?php if ($success_message): ?>
            <div class="alert success"><?php echo htmlspecialchars($success_message, ENT_QUOTES, 'UTF-8'); ?></div>
        <?php endif; ?>
        <?php if ($error_message): ?>
            <div class="alert error"><?php echo htmlspecialchars($error_message, ENT_QUOTES, 'UTF-8'); ?></div>
        <?php endif; ?>

        <!-- 投稿フォーム -->
        <div class="form-container">
            <form action="index.php" method="post" enctype="multipart/form-data">
                <!-- CSRFトークン埋め込み -->
                <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8'); ?>">
                
                <div class="input-group">
                    <label>ニックネーム</label>
                    <input type="text" name="name" required placeholder="例:名無しさん">
                </div>
                <div class="input-group">
                    <label>コメント</label>
                    <textarea name="comment" required placeholder="今の気持ちをつぶやこう"></textarea>
                </div>
                <div class="input-group">
                    <label>画像(任意)</label>
                    <input type="file" name="image" accept="image/*">
                </div>
                <button type="submit" name="submit" class="submit-btn">投稿する</button>
            </form>
        </div>

        <hr>

        <!-- 投稿一覧 -->
        <div class="timeline">
            <?php foreach ($posts as $post): ?>
                <article class="post">
                    <div class="post-header">
                        <span class="post-name">
                            <!-- XSS対策:必ずhtmlspecialcharsを通す -->
                            <?php echo htmlspecialchars($post['name'], ENT_QUOTES, 'UTF-8'); ?>
                        </span>
                        <span class="post-date">
                            <?php echo date('Y/m/d H:i', strtotime($post['created_at'])); ?>
                        </span>
                    </div>
                    <div class="post-content">
                        <p>
                            <!-- nl2brで改行を反映 -->
                            <?php echo nl2br(htmlspecialchars($post['comment'], ENT_QUOTES, 'UTF-8')); ?>
                        </p>
                        <?php if (!empty($post['image_path'])): ?>
                            <img src="./images/<?php echo htmlspecialchars($post['image_path'], ENT_QUOTES, 'UTF-8'); ?>" alt="投稿画像" class="post-image">
                        <?php endif; ?>
                    </div>
                    
                    <!-- 編集・削除エリア -->
                    <div class="post-actions">
                        <a href="edit.php?id=<?php echo $post['id']; ?>">編集</a>
                        
                        <!-- 削除はPOSTで行う(重要) -->
                        <form action="delete.php" method="post" style="display: inline;" onsubmit="return confirm('本当に削除しますか?');">
                            <input type="hidden" name="id" value="<?php echo $post['id']; ?>">
                            <!-- 削除時もCSRFトークンを送る -->
                            <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8'); ?>">
                            <button type="submit">削除</button>
                        </form>
                    </div>
                </article>
            <?php endforeach; ?>

            <?php if (empty($posts)): ?>
                <p style="text-align: center; color: #888;">まだ投稿はありません。</p>
            <?php endif; ?>
        </div>
    </main>
    <footer>
        <p>&copy; 2026 Linux Koubou BBS Project</p>
    </footer>
</body>
</html>

ファイル3:edit.php(編集画面)

既存の投稿を編集するファイルです。
/var/www/html/bbs/edit.php として保存してください。

<?php
session_start();
$dsn = 'mysql:dbname=app_db;host=localhost;charset=utf8mb4';
$user = 'app_user';
$password = 'password123';

// セッションからトークン取得(なければ空)
$csrf_token = $_SESSION['csrf_token'] ?? '';

$message = null;
$error_message = null;
$id = $_GET['id'] ?? null;

// IDがない場合はトップへ戻す
if (!$id) {
    header('Location: index.php');
    exit;
}

try {
    $pdo = new PDO($dsn, $user, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    // ▼ 更新処理(POST時)
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        
        // CSRFチェック
        if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
            die('不正なリクエストです。');
        }

        $name = $_POST['name'] ?? '';
        $comment = $_POST['comment'] ?? '';

        if (empty($name) || empty($comment)) {
            $error_message = '名前とコメントは必須です。';
        } else {
            $sql = "UPDATE posts SET name = :name, comment = :comment WHERE id = :id";
            $stmt = $pdo->prepare($sql);
            $stmt->bindValue(':name', $name, PDO::PARAM_STR);
            $stmt->bindValue(':comment', $comment, PDO::PARAM_STR);
            $stmt->bindValue(':id', $id, PDO::PARAM_INT);
            $stmt->execute();

            $message = '更新しました!';
        }
    }

    // ▼ 現在のデータを取得
    $stmt = $pdo->prepare("SELECT * FROM posts WHERE id = :id");
    $stmt->bindValue(':id', $id, PDO::PARAM_INT);
    $stmt->execute();
    $post = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$post) {
        header('Location: index.php');
        exit;
    }

} catch (PDOException $e) {
    $error_message = 'エラー: ' . $e->getMessage();
}
?>

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>投稿の編集</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header><h1>投稿の編集</h1></header>
    <main>
        <?php if ($message): ?>
            <div class="alert success"><?php echo htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); ?></div>
        <?php endif; ?>
        <?php if ($error_message): ?>
            <div class="alert error"><?php echo htmlspecialchars($error_message, ENT_QUOTES, 'UTF-8'); ?></div>
        <?php endif; ?>

        <div class="form-container">
            <form action="edit.php?id=<?php echo $post['id']; ?>" method="post">
                <!-- CSRFトークン -->
                <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8'); ?>">
                
                <div class="input-group">
                    <label>ニックネーム</label>
                    <input type="text" name="name" required value="<?php echo htmlspecialchars($post['name'], ENT_QUOTES, 'UTF-8'); ?>">
                </div>
                <div class="input-group">
                    <label>コメント</label>
                    <textarea name="comment" required><?php echo htmlspecialchars($post['comment'], ENT_QUOTES, 'UTF-8'); ?></textarea>
                </div>
                <button type="submit" class="submit-btn">更新する</button>
            </form>
        </div>
        
        <div style="text-align: center; margin-top: 20px;">
            <a href="index.php">一覧に戻る</a>
        </div>
    </main>
</body>
</html>

ファイル4:delete.php(削除処理)

画面を持たず、削除処理だけを行ってトップページに戻るファイルです。
/var/www/html/bbs/delete.php として保存してください。

<?php
session_start();
$dsn = 'mysql:dbname=app_db;host=localhost;charset=utf8mb4';
$user = 'app_user';
$password = 'password123';

// POST以外のアクセスは拒否(セキュリティ対策)
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: index.php');
    exit;
}

// CSRFトークンチェック(ここが重要)
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
    die('不正なリクエストです。(トークン不一致)');
}

$id = $_POST['id'] ?? null;

if ($id) {
    try {
        $pdo = new PDO($dsn, $user, $password);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        // 1. 画像ファイルを削除するための情報取得
        $stmt = $pdo->prepare("SELECT image_path FROM posts WHERE id = :id");
        $stmt->bindValue(':id', $id, PDO::PARAM_INT);
        $stmt->execute();
        $post = $stmt->fetch(PDO::FETCH_ASSOC);

        // 2. 画像ファイル削除(サーバーのゴミ掃除)
        if (!empty($post['image_path'])) {
            $image_file = '/var/www/html/bbs/images/' . $post['image_path'];
            if (file_exists($image_file)) {
                unlink($image_file);
            }
        }

        // 3. DBレコード削除
        $stmt = $pdo->prepare("DELETE FROM posts WHERE id = :id");
        $stmt->bindValue(':id', $id, PDO::PARAM_INT);
        $stmt->execute();

    } catch (PDOException $e) {
        // 必要に応じてエラーログを出力
    }
}

// 処理完了後、トップへ戻る
header('Location: index.php');
exit;
?>

まとめ

以上で、全ソースコードの解説は終了です。
この4つのファイルをサーバーに配置すれば、安全で機能的な掲示板が動作します。

もし将来、機能を拡張したくなったら(例:ログイン機能をつける、ページネーションをつけるなど)、このコードをベースにして少しずつ書き換えてみてください。
動いているコードをいじって壊し、また直す。それがプログラミング上達の最短ルートです。

リナックス先生

これにて「LAMP環境構築講座」、本当に終了よ!
エラーと戦いながらここまで辿り着いた経験は、あなたのエンジニアとしての大きな財産になるはず。
またどこかで会いましょう。Happy Hacking!

▼エンジニアとしての第一歩はここから(推奨VPS)

【2026年最新】Linuxサーバー構築におすすめのVPS比較3選!現役エンジニアが速度とコスパで厳選
Linuxの勉強、まだ「自分のPC」でやって消耗していませんか?「Linuxを覚えたいけど、環境構築でエラーが出て先に進めない…」「VirtualBoxを入れたらパソコンが重くなった…」これは、Linux学習を始める9割の人がぶつかる壁です...

コメント