「杏林同學錄」留言簿(class/notebook/)僅對已登入班級成員開放,提供「分頁瀏覽、發文、刪除管理」三大核心功能。程式碼採用Session 驗證守衛、LIMIT 分頁、nl2br(strip_tags()) 內容淨化、IP 記錄等經典模式,是研究「小型社區互動核心」的優良教材。
index.php / addnote.php 共用 Session 檢查<?php
session_start();
if (!isset($_SESSION['userregister']) || empty($_SESSION['userregister'])) {
header('Location: ../index.php'); // 導向前台登入
exit;
}
$user = $_SESSION['userregister']; // 當前登入用戶名
?>
現代化建議:抽離為 AuthMiddleware 或 require_auth() 函數,支援 AJAX 請求回傳 401 JSON、頁面請求重導並保留 redirect_url。
index.php 逐行解析$result = mysql_query("SELECT * FROM notebook", $db);
$max = mysql_num_rows($result); // 總筆數(全表掃描,效能差)
$page = $_GET['page'] ?? 1;
$perPage = 10;
$p = ceil($max / $perPage); // 總頁數
$low = $perPage * ($page - 1); // OFFSET
$x = ($page == $p && $max % $perPage) ? $max % $perPage : $perPage; // 最後一頁筆數
if ($max == 0) $x = 0;
$result = mysql_query("SELECT * FROM notebook ORDER BY time DESC LIMIT $low, $x", $db);
| 問題 | 影響 | 現代方案 |
|---|---|---|
SELECT * 兩次查詢 | 全表掃描兩次,大數據極慢 | SELECT COUNT(*) + SELECT id, user, time, title, nnote ... 只查需要欄位 |
LIMIT $low, $x 直接拼接 | SQL 注入(雖 $low/$x 經運算,但不保險) | PDO LIMIT :offset, :limit 繫結參數 |
| 每則留言額外查詢用戶表 | N+1 問題:10 則留言 = 11 次查詢 | JOIN 查詢或批次載入 WHERE user IN (...) |
| 時間排序無索引 | 檔案排序 | 加索引 KEY idx_time (time DESC) |
$stmt = $pdo->prepare("
SELECT n.id, n.user, n.time, n.ip, n.title, n.nnote,
u.name, u.signature, u.email, u.face
FROM notebook n
JOIN users u ON u.username = n.user
ORDER BY n.time DESC
LIMIT :offset, :limit
");
$stmt->bindValue(':offset', ($page-1)*$perPage, PDO::PARAM_INT);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->execute();
$notes = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 總數查詢(可快取 30 秒)
$total = $pdo->query("SELECT COUNT(*) FROM notebook")->fetchColumn();
echo 拼接到模板引擎原始碼在迴圈中大量 echo "<table...>",混雜變數插值、樣式類別、圖片路徑。現代 Twig 範例:
{# notes_list.twig #}
{% for note in notes %}
<article class="note-card">
<header class="note-header">
<img src="{{ asset('images/face/icon'~note.face~'.gif') }}" width="32" height="32" class="avatar">
<strong>{{ note.name }}</strong>
<time datetime="{{ note.time|date('c') }}">{{ note.time|date('Y-m-d H:i') }}</time>
<a href="mailto:{{ note.email }}" title="發送郵件"><img src="{{ asset('images/email.gif') }}" alt="Email"></a>
<span class="ip" title="發文 IP">{{ note.ip }}</span>
</header>
<h3 class="note-title">{{ note.title }}</h3>
<div class="note-content">{{ note.nnote|raw }}</div> {# 已經過 nl2br(strip_tags) #}
<footer class="note-signature">{{ note.signature|nl2br }}</footer>
</article>
{% else %}
<p class="empty">目前沒有留言,搶個沙發吧!</p>
{% endfor %}
注意:nnote 已在寫入時做 nl2br(strip_tags()),故此處可用 |raw;若改為儲存原始文字,模板需自行 |nl2br|escape。
{# pagination.twig #}
addnote.php$time = date('Y年m月d日 H:i:s A')、$ip = $REMOTE_ADDR
3. $title = strip_tags($top)、$nnote = nl2br(strip_tags($content))
4. 必填檢查 → INSERT INTO notebook (user, time, ip, title, nnote) VALUES ...
5. 成功提示「留言成功!」
_token,驗證 $_POST['_token'] === $_SESSION['_token']。strip_tags() 會殘留屬性(如 onclick),建議使用 HTMLPurifier 白名單模式,或採用 Markdown + Parsedown(禁止 HTML)。$REMOTE_ADDR 在反向代理下不準,需讀取 $_SERVER['HTTP_X_FORWARDED_FOR'](信任代理清單)。header('Location: index.php?page=1') 而非直接輸出文字,避免重新整理重複提交。old())。<?php
// addnote.php
session_start();
require __DIR__.'/../config.php'; // PDO $pdo
require __DIR__.'/../lib/csrf.php'; // validate_csrf()
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
validate_csrf(); // 失敗拋出異常
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
$errors = [];
if ($title === '') $errors['title'] = '標題不可為空';
if ($content === '') $errors['content'] = '內容不可為空';
if (mb_strlen($title) > 80) $errors['title'] = '標題過長';
if (mb_strlen($content) > 5000) $errors['content'] = '內容過長';
if (!$errors) {
try {
$ip = getClientIp(); // 處理代理
$safeContent = nl2br(htmlspecialchars($content, ENT_QUOTES)); // 或 HTMLPurifier
$stmt = $pdo->prepare("INSERT INTO notebook (user, time, ip, title, nnote) VALUES (?, NOW(), ?, ?, ?)");
$stmt->execute([$_SESSION['userregister'], $ip, $title, $safeContent]);
header('Location: index.php?page=1&posted=1');
exit;
} catch (PDOException $e) {
error_log($e->getMessage());
$errors['db'] = '系統繁忙,請稍後再試';
}
}
// 失敗:回顯表單
$_SESSION['old'] = ['title' => $title, 'content' => $content];
$_SESSION['errors'] = $errors;
header('Location: addnote.php');
exit;
}
// GET:顯示表單
$old = $_SESSION['old'] ?? ['title'=>'', 'content'=>''];
$errors = $_SESSION['errors'] ?? [];
unset($_SESSION['old'], $_SESSION['errors']);
?>
<!DOCTYPE html>... <form method="post"> {{ csrf_field() }} ... </form>
CREATE TABLE `notebook` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user` VARCHAR(32) NOT NULL, -- 關聯 users.username
`title` VARCHAR(120) NOT NULL,
`content` MEDIUMTEXT NOT NULL, -- 儲存已淨化 HTML 或原始 Markdown
`ip` VARBINARY(16) NOT NULL, -- IPv6 相容,INET6_ATON 儲存
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME DEFAULT NULL, -- 軟刪除
PRIMARY KEY (`id`),
KEY `idx_user_time` (`user`, `created_at`),
KEY `idx_time` (`created_at`),
CONSTRAINT `fk_note_user` FOREIGN KEY (`user`) REFERENCES `users`(`username`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
delnote.php(原始碼未給出,補充設計)UPDATE notebook SET deleted_at=NOW() WHERE id=?。/notebook/delete → 回傳 JSON → 前端移除 DOM。admin_log (operator, action, target_id, reason, ip, time)。「杏林同學錄」留言簿雖小,卻包含認證守衛、分頁查詢、N+1 優化、內容淨化、CSRF 防護、PRG 模式、IP 處理、軟刪除、權限控制等 Web 社區核心要素。重構路徑清晰:
mysql_*。至此,「杏林同學錄」系列解析完結。從兩階段註冊、管理後台、留言簿,我們看到了一個完整的封閉社群平台在 PHP 4/5 時代的實現縮影,也梳理出通往現代 Laravel/Symfony/Api Platform 架構的遷移階梯。願這些經典代碼啟發你在新技術棧上構建更安全、可維護、可擴展的應用。