原始 say.php 混雜:身分驗證、CSRF 缺失、SQL 拼接、HTML/JS 混寫、硬編碼圖片路徑、htmlspecialchars 僅用於輸出。本文重構為:
CREATE TABLE `bbs_posts` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`parent_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '回覆對象,NULL=主題',
`user_id` VARCHAR(64) NOT NULL COMMENT '發文者 ID',
`title` VARCHAR(200) NOT NULL,
`content` MEDIUMTEXT NOT NULL,
`ip` VARBINARY(16) NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_parent` (`parent_id`),
INDEX `idx_user_time` (`user_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
<?php
// say.php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php'; // $pdo, $twig, $session, $csrf
$replyTo = (int)($_GET['reply_to'] ?? 0);
$parent = null;
if ($replyTo > 0) {
$stmt = $pdo->prepare('SELECT id, title FROM bbs_posts WHERE id = ?');
$stmt->execute([$replyTo]);
$parent = $stmt->fetch(PDO::FETCH_ASSOC);
}
// 必須登入
$userId = $session->get('user_id');
if (!$userId) {
$_SESSION['flash'] = ['type' => 'warning', 'msg' => '請先登入'];
header('Location: login.php?redirect=' . urlencode($_SERVER['REQUEST_URI']));
exit;
}
// 處理 POST
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verifyCsrfToken($_POST['csrf_token'] ?? '')) {
$errors['_form'] = '表單驗證失敗,請重新整理';
} else {
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($title === '') $errors['title'] = '標題不可為空';
if (mb_strlen($title) > 200) $errors['title'] = '標題過長';
if ($content === '') $errors['content'] = '內容不可為空';
if (mb_strlen($content) > 1_000_000) $errors['content'] = '內容過長';
if (empty($errors)) {
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare('INSERT INTO bbs_posts (parent_id, user_id, title, content, ip) VALUES (?, ?, ?, ?, ?)');
$stmt->execute([
$replyTo ?: null,
$userId,
$title,
$content,
inet_pton($_SERVER['REMOTE_ADDR'])
]);
$newId = $pdo->lastInsertId();
// 若是回覆,更新父主題的回覆計數或最後回覆時間(可擴充)
if ($replyTo) {
$pdo->prepare('UPDATE bbs_posts SET updated_at = NOW() WHERE id = ?')->execute([$replyTo]);
}
$pdo->commit();
$_SESSION['flash'] = ['type' => 'success', 'msg' => '發文成功'];
header('Location: thread.php?id=' . ($replyTo ?: $newId));
exit;
} catch (Throwable $e) {
$pdo->rollBack();
$errors['_form'] = '資料庫錯誤:' . $e->getMessage();
}
}
}
}
// 顯示表單
echo $twig->render('say.twig', [
'parent' => $parent,
'replyTo' => $replyTo,
'csrf' => generateCsrfToken(),
'errors' => $errors ?? [],
'old' => ['title' => $_POST['title'] ?? ($parent ? 'Re: ' . $parent['title'] : ''), 'content' => $_POST['content'] ?? ''],
'flash' => $_SESSION['flash'] ?? null
]);
unset($_SESSION['flash']);
?>
{# templates/say.twig #}
{{ parent ? '回覆:' ~ parent.title|e : '發表新主題' }}
{{ parent ? '回覆主題' : '發表新主題' }}
返回列表
{% if flash %}
{{ flash.msg }}
{% endif %}
{% if parent %}
原主題
{{ parent.title|e }}
{% endif %}
| 風險 | 原始碼 | 重構版 |
|---|---|---|
| SQL Injection | 字串拼接 $sql | PDO Prepared Statements |
| XSS | 僅 htmlspecialchars 輸出 | Twig 自動跳脫 + CSP 標頭 |
| CSRF | 無 | Token 驗證 + SameSite Cookie |
| 未驗證登入 | IP 查 user_stus | Session + 中介層強制登入 |
| 回覆關聯 | 固定欄位 r1~r5 | parent_id 遞歸結構 |
| 編碼問題 | gb2312、混用 | 全站 UTF-8、資料庫 utf8mb4 |
league/commonmark + 安全白名單@username 發送通知