留言板是 PHP 入門經典項目,涵蓋表單處理、數據庫交互、增刪改查(CRUD)、安全防護等核心技能。本文基於原教程代碼,重構為現代化、安全、可維護的版本,採用 PDO 預處理語句、XSS 防護、錯誤處理等最佳實踐。
msgboard/
├── config.php # 數據庫配置
├── functions.php # 通用函數
├── index.php # 留言列表 + 發布表單
├── delete.php # 刪除留言
├── edit.php # 編輯留言
└── style.css # 簡單樣式
CREATE TABLE `messages` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) NOT NULL,
`email` VARCHAR(100) DEFAULT NULL,
`content` TEXT NOT NULL,
`ip` VARCHAR(45) NOT NULL, -- 支援 IPv6
`user_agent` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
<?php
// config.php
declare(strict_types=1);
const DB_HOST = '127.0.0.1';
const DB_NAME = 'msgboard';
const DB_USER = 'root';
const DB_PASS = 'your_secure_password';
const DB_CHARSET = 'utf8mb4';
try {
$pdo = new PDO(
"mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET,
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 真實預處理
]
);
} catch (PDOException $e) {
// 生產環境記錄日誌,不直接輸出
error_log("Database connection failed: " . $e->getMessage());
die('系統維護中,請稍後再試。');
}
?>
<?php
// functions.php
declare(strict_types=1);
/** 安全輸出:防 XSS */
function h(string $str): string {
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
/** 獲取客戶端真實 IP */
function getClientIp(): string {
$headers = ['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
$ips = explode(',', $_SERVER[$header]);
return trim($ips[0]);
}
}
return 'unknown';
}
/** 簡單防刷:同 IP 60 秒內限制提交 */
function checkRateLimit(PDO $pdo, string $ip): bool {
$stmt = $pdo->prepare("SELECT 1 FROM messages WHERE ip = ? AND created_at > DATE_SUB(NOW(), INTERVAL 60 SECOND) LIMIT 1");
$stmt->execute([$ip]);
return !$stmt->fetch();
}
?>
<?php
// index.php
declare(strict_types=1);
require_once 'config.php';
require_once 'functions.php';
$errors = [];
$success = '';
// 處理發布留言
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'post') {
// CSRF 簡易驗證(生產環境應用專用 Token)
if (!isset($_POST['csrf']) || $_POST['csrf'] !== ($_SESSION['csrf'] ?? '')) {
$errors[] = '請求無效,請刷新頁面重試。';
} else {
$username = trim($_POST['username'] ?? '');
$email = trim($_POST['email'] ?? '');
$content = trim($_POST['content'] ?? '');
$ip = getClientIp();
// 驗證
if (mb_strlen($username) < 2 || mb_strlen($username) > 50) {
$errors[] = '用戶名長度需 2-50 字符。';
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = '郵箱格式不正確。';
}
if (mb_strlen($content) < 1 || mb_strlen($content) > 2000) {
$errors[] = '留言內容 1-2000 字符。';
}
if (!checkRateLimit($pdo, $ip)) {
$errors[] = '操作過於頻繁,請 60 秒後再試。';
}
if (empty($errors)) {
try {
$stmt = $pdo->prepare("INSERT INTO messages (username, email, content, ip, user_agent) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$username, $email, $content, $ip, $_SERVER['HTTP_USER_AGENT'] ?? '']);
$success = '留言發布成功!';
// 重新生成 CSRF Token
$_SESSION['csrf'] = bin2hex(random_bytes(32));
} catch (PDOException $e) {
error_log("Insert message failed: " . $e->getMessage());
$errors[] = '系統錯誤,請稍後再試。';
}
}
}
}
// 初始化 CSRF Token
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
// 獲取留言列表(分頁)
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 10;
$offset = ($page - 1) * $perPage;
$totalStmt = $pdo->query("SELECT COUNT(*) FROM messages");
$total = (int)$totalStmt->fetchColumn();
$totalPages = ceil($total / $perPage);
$stmt = $pdo->prepare("SELECT * FROM messages ORDER BY created_at DESC LIMIT ? OFFSET ?");
$stmt->bindValue(1, $perPage, PDO::PARAM_INT);
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
$stmt->execute();
$messages = $stmt->fetchAll();
?>
PHP 留言板
📝 留言板
發布留言
= h($success) ?>
- = h($e) ?>
<?php
// delete.php
require_once 'config.php';
require_once 'functions.php';
$id = (int)($_GET['id'] ?? 0);
if ($id > 0) {
$stmt = $pdo->prepare("DELETE FROM messages WHERE id = ?");
$stmt->execute([$id]);
}
header('Location: index.php');
exit;
<?php
// edit.php
require_once 'config.php';
require_once 'functions.php';
$id = (int)($_GET['id'] ?? 0);
$msg = null;
if ($id > 0) {
$stmt = $pdo->prepare("SELECT * FROM messages WHERE id = ?");
$stmt->execute([$id]);
$msg = $stmt->fetch();
}
if (!$msg) {
die('留言不存在');
}
// 處理更新
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$content = trim($_POST['content'] ?? '');
if (mb_strlen($content) >= 1 && mb_strlen($content) <= 2000) {
$stmt = $pdo->prepare("UPDATE messages SET content = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$content, $id]);
header('Location: index.php');
exit;
}
$error = '內容長度需 1-2000 字符';
}
?>
編輯留言
✏️ 編輯留言 #= $msg['id'] ?>
用戶:= h($msg['username']) ?> | 時間:= h($msg['created_at']) ?>
= h($error) ?>
/* style.css */
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.6; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
.container { background: #fff; border-radius: 8px; padding: 2rem; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1, h2 { color: #333; border-bottom: 1px solid #eee; padding-bottom: 0.5rem; }
.field { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.3rem; font-weight: 500; }
.required { color: #e74c3c; }
input, textarea { width: 100%; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
button { background: #3498db; color: #fff; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: 1rem; }
button:hover { background: #2980b9; }
.alert { padding: 1rem; border-radius: 4px; margin-bottom: 1rem; }
.alert.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.message { border: 1px solid #eee; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
.message header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.9rem; color: #666; }
.message footer { margin-top: 0.5rem; font-size: 0.85rem; }
.message footer a { margin-right: 1rem; color: #3498db; text-decoration: none; }
.pagination { display: flex; justify-content: center; gap: 1rem; margin-top: 2rem; }
.pagination a { color: #3498db; text-decoration: none; }
.empty { text-align: center; color: #999; padding: 3rem; }
| 風險點 | 原教程寫法 | 本教程改進 |
|---|---|---|
| SQL 注入 | 直接拼接變數進 SQL | PDO 預處理語句(參數綁定) |
| XSS 攻擊 | 直接輸出用戶輸入 | htmlspecialchars() 統一轉義 + nl2br() 保留換行 |
| CSRF 攻擊 | 無防護 | Session 型 CSRF Token 驗證 |
| 暴力刷屏 | 無限制 | IP 級 60 秒頻率限制 |
| 編碼問題 | latin1/utf8 混用 | 全鏈路 utf8mb4 |
| 錯誤暴露 | 直接 die(mysql_error()) | 記錄日誌,用戶端顯示友好提示 |
| 資源洩漏 | 未關閉連接 | PDO 自動管理,腳本結束自動釋放 |
WHERE id < last_id)替代 OFFSET。parent_id 字段,遞歸渲染樹狀結構。user_id,權限控制編輯/刪除。這個重構版留言板雖然功能基礎,但奠定了安全、規範、可擴展的代碼基因。從這裡起步,逐步引入框架(Laravel/Symfony)、ORM、測試、CI/CD,即可成長為企業級應用開發者。