【連載付録】エラーで諦めかけたあなたへ贈る「正解コード」
未経験から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>© 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)



コメント