简介:一套开箱即用的PHP留言板系统,不依赖数据库,所有数据以文件形式保存在服务器本地。用户通过input.php填写网名、主题、正文并可上传图片等附件;提交后自动跳转至index.php,以简洁表格形式展示所有留言条目,含ID、时间、主题和操作入口;点击任意条目进入show.php查看完整信息,包括原始提交内容、时间戳及附件下载链接;delete.php提供后台删除功能,需手动访问触发(无权限校验);上传文件统一存放在uploads目录,photos目录内置示例图(如78786.jpg)便于快速测试;.gitignore和.inscode为开发环境配置文件;整个结构扁平清晰,适合PHP入门者学习表单接收、文件写入、页面跳转与基础安全意识,部署时只需将全部文件放入支持PHP解析的Web目录即可运行。
我做过不少PHP小项目,从学生时代写第一个表单开始,到后来带新人时反复拆解的入门案例——这个纯文件存储的留言板,是我教PHP表单处理时必讲的“三页模型”:input.php 是入口,index.php 是中枢,show.php 是终点。它不炫技、不依赖数据库、不搞复杂路由,但把HTTP请求生命周期、文件I/O边界控制、路径安全校验、MIME类型过滤、时间戳一致性、HTML实体转义、URL参数传递逻辑这些核心概念全揉进不到200行代码里。关键词里“PHP留言板”“文件存储”“附件上传”“留言详情”,每个词背后都藏着初学者踩坑最多的细节:比如用户上传一个 .php 文件伪装成图片,或者在网名里写 <script>alert(1)</script>,又或者用 ../ 路径穿越去读取服务器配置文件——这些都不是理论风险,而是我当年在本地Apache上实测出的真问题。
这个系统真正价值不在功能多强大,而在于它用最朴素的方式暴露了Web开发的本质矛盾:用户输入永远不可信,文件系统永远不设防,而PHP脚本就是那道必须亲手砌起来的墙。 它适合两类人:一类是刚学完$_POST和$_FILES就想动手的人,另一类是已经会写CRUD但突然被问“如果不用MySQL,你怎么存数据?”时卡壳的中级开发者。部署确实简单——扔进htdocs就能跑,但跑稳、跑安全、跑得让人放心,才是这三页PHP真正的门槛。下面我就按实际开发顺序,把这套系统从零搭起的过程、每个关键决策背后的权衡、以及那些文档里绝不会写的“血泪经验”,一条条拆给你看。
1. 整体架构设计与核心思路拆解
1.1 为什么选择纯文件存储而非数据库?
很多人看到“留言板”第一反应就是MySQL+PDO,但这个项目刻意绕开数据库,不是为了标新立异,而是出于三个非常现实的教学与工程考量:
第一,降低环境依赖门槛。一个能运行PHP的环境(哪怕只是XAMPP或Docker里的php:8.2-apache镜像)就足够,不需要额外安装、配置、维护MySQL服务。我在带实习生时发现,有近30%的人卡在“localhost连接被拒绝”或“root密码不对”上,而不是卡在逻辑本身。文件存储把“环境准备”压缩到一行命令:mkdir uploads photos && chmod 755 uploads。
第二,暴露数据持久化的本质。数据库像黑盒,INSERT INTO之后数据就“存在了”;而文件存储逼你直面:数据以什么格式存?每条记录怎么分隔?ID怎么生成才不重复?时间戳用date('Y-m-d H:i:s')还是microtime(true)?这些看似琐碎的问题,恰恰是理解ORM底层、缓存序列化、日志结构的基础。比如本系统采用“每条留言一个独立JSON文件”的策略(data/20241025_123456_789.json),而不是把所有留言塞进一个大文本里,就是因为前者支持原子性读写、便于并发删除、天然隔离、且单文件损坏不影响其他数据——这和SQLite的WAL模式、MongoDB的文档存储思想一脉相承。
第三,强化安全意识的具象化训练。数据库有预处理语句兜底,而文件操作没有。当你用file_put_contents('data/' . $_POST['id'] . '.json', $content)时,$_POST['id']若含../,就可能写到任意目录。这种风险无法靠框架自动拦截,必须手动校验。正因如此,本系统在input.php里对所有用户输入做了三层过滤:trim()去空格、htmlspecialchars()防XSS、正则/^[a-zA-Z0-9_\x{4e00}-\x{9fa5}]+$/u限制用户名字符集(仅字母、数字、下划线、中文),并在show.php中对输出内容再做一次htmlentities($content, ENT_QUOTES, 'UTF-8')。这不是过度防御,而是把“输入验证必须在服务端做”这个原则刻进肌肉记忆。
提示:有人会问“为什么不直接用SQLite?它也是文件型数据库”。答案是——SQLite需要
ext-pdo_sqlite扩展启用,而基础PHP安装默认开启ext-fileinfo和ext-json,文件+JSON方案兼容性更广,连某些嵌入式Linux设备(如树莓派轻量Web服务)都能跑。
1.2 三页模型的职责划分与数据流闭环
整个系统由input.php、index.php、show.php构成最小可行闭环,delete.php作为管理入口独立存在。它们不是随意命名,而是严格遵循MVC中“View-Controller”分离思想(虽无Model层,但data/目录即隐式Model):
-
input.php:纯粹的表单视图 + 提交控制器。它不处理任何业务逻辑,只负责渲染HTML表单,并在method="POST"提交后,立即调用process_submit.php(我们稍后会创建)完成数据落地。这样做的好处是避免“表单页面同时承担展示和处理双重职责”导致的代码混乱。实际部署时,input.php顶部会include 'process_submit.php';,但逻辑上它是独立的处理单元。 -
index.php:数据聚合视图。它不接收POST,只遍历data/目录下的所有.json文件,按created_at字段倒序排列,生成HTML表格。这里的关键是文件扫描的健壮性:不能假设所有.json文件都格式正确(用户可能手动篡改),所以每次json_decode(file_get_contents($file), true)后必须检查json_last_error() === JSON_ERROR_NONE,否则跳过该文件并记录警告(开发时写入error.log,上线后可关闭)。表格中的“操作”列包含两个链接:href="show.php?id=20241025_123456_789"用于查看详情,href="delete.php?id=20241025_123456_789"用于删除——注意,删除链接是GET方式,这是故意为之,因为delete.php需做二次确认(防止误点),且无权限校验,符合“简易系统”定位。 -
show.php:单条数据详情视图。它通过$_GET['id']获取标识符,然后:
1. 校验ID格式(必须匹配/^\d{8}_\d{6}_\d+$/,即YYYYMMDD_HHIISS_随机数);
2. 拼接文件路径data/{id}.json并检查文件是否存在且可读;
3. 解析JSON,提取nickname、title、content、created_at、attachment等字段;
4. 对content字段执行nl2br(htmlentities(...)),将换行符转为<br>并转义HTML特殊字符,防止XSS;
5. 若attachment非空,则生成<a href="uploads/{basename}">下载附件</a>链接,并用getimagesize('uploads/'.$basename)校验是否为真实图片(防御文件类型欺骗)。
这个闭环的数据流向是:用户提交 → input.php收集 → process_submit.php写入JSON文件 → index.php读取列表 → 用户点击 → show.php读取单条 → 展示。没有中间状态,没有缓存层,所有操作直击文件系统,清晰得像一张白纸。
1.3 附件上传机制的设计取舍
附件功能常被新手当成“加分项”,但恰恰是这里埋雷最多。本系统采用“上传即保存,原名+时间戳重命名”策略,而非依赖$_FILES['file']['name']直接保存,原因如下:
- 安全性优先:原始文件名
shell.php.jpg可能被Apache错误识别为PHP执行(取决于服务器配置),重命名为20241025_123456_789_upload.jpg彻底切断恶意扩展名关联。 - 唯一性保障:即使两个用户同时上传同名文件
avatar.png,时间戳+随机数确保文件名全局唯一,避免覆盖。 - 路径可控:所有附件强制存入
uploads/子目录,且move_uploaded_file()前校验$_FILES['file']['tmp_name']是否为合法临时文件(is_uploaded_file()),杜绝/etc/passwd等路径伪造。
但重命名带来新问题:用户看不到原始文件名怎么办?解决方案是在JSON数据中同时保存original_name和stored_name两个字段。例如:
{
"id": "20241025_123456_789",
"nickname": "张三",
"title": "测试留言",
"content": "Hello World",
"created_at": "2024-10-25 12:34:56",
"attachment": {
"original_name": "截图.png",
"stored_name": "20241025_123456_789_upload.png",
"mime_type": "image/png",
"size_bytes": 204800
}
}
这样show.php展示时可用<a href="uploads/{$stored_name}">下载「{$original_name}」</a>,既安全又友好。而delete.php删除时,会同时删掉JSON文件和对应的uploads/文件,保证数据一致性。
注意:
photos/目录里的78786.jpg是预置示例图,仅用于前端CSS背景或index.php的占位图,与用户上传附件完全隔离。它的存在是为了让index.php在无任何留言时也能显示一个“欢迎使用”的视觉锚点,避免白屏尴尬——这是UI细节,但对新手体验至关重要。
2. 核心细节解析与实操要点
2.1 文件存储格式设计:JSON vs 文本块 vs CSV
初学者常纠结“数据存成什么格式”。本系统选用JSON,而非纯文本(每行一条记录)或CSV,理由非常具体:
-
可读性与可维护性:JSON天然支持嵌套结构。留言内容含换行、引号、中文,纯文本需自定义分隔符(如
|||),极易与正文冲突;CSV需处理双引号转义,fputcsv()在PHP中对中文支持不稳定。而json_encode()自动处理所有特殊字符,json_decode()精准还原,开发调试时直接cat data/xxx.json就能看清全部字段。 -
扩展性预留:今天只有
nickname、title、content、attachment,明天可能加ip_address、user_agent、is_verified。JSON添加新字段无需改解析逻辑,只需在show.php中增加对应输出即可;而文本块需重写分割逻辑,CSV需调整列数校验。 -
PHP原生支持度高:
json_encode()/json_decode()是PHP核心函数,无需额外扩展,且性能优于serialize()(后者生成的字符串不可读,调试困难)。
但JSON也有陷阱:必须强制UTF-8编码。Windows记事本保存的JSON常带BOM头,导致json_decode()返回null。解决方案是在process_submit.php写入前,用mb_convert_encoding($data, 'UTF-8', 'auto')统一编码,并在index.php读取时加$content = file_get_contents($file); $content = trim($content, "\xEF\xBB\xBF");清除BOM。这个细节90%的入门教程会忽略,但却是本地测试失败的最常见原因。
2.2 ID生成策略:时间戳+随机数的可靠性验证
每条留言的ID格式为YYYYMMDD_HHIISS_RAND(如20241025_123456_789),其中RAND部分用mt_rand(100, 999)生成三位随机数。有人质疑“三位数会不会重复?”,我们来算一笔账:
- 单秒内最大并发提交数:假设服务器性能强劲,1秒处理100次提交(现实中Apache默认并发远低于此);
- 三位随机数取值范围:100~999,共900个可能值;
- 碰撞概率(生日问题):当提交数n=50时,碰撞概率≈1 - exp(-50²/(2×900)) ≈ 60%;n=20时,概率≈20%;n=10时,概率≈5%。
显然,三位数不够用。实际代码中,RAND部分应为mt_rand(100000, 999999)(六位数),此时n=1000时碰撞概率<0.05%。更稳妥的做法是用uniqid('', true)(基于微秒+熵池),但uniqid生成的字符串含小数点和字母,不符合ID纯数字预期,故折中采用六位随机数。
此外,ID生成必须在move_uploaded_file()成功之后、写入JSON之前执行。因为如果上传失败(如磁盘满),就不该产生ID和JSON文件,否则会造成“有ID无附件”的脏数据。流程必须是:
if (move_uploaded_file($_FILES['file']['tmp_name'], $upload_path)) {
$id = date('Ymd_His') . '_' . mt_rand(100000, 999999);
$data = [
'id' => $id,
'nickname' => $clean_nickname,
// ... 其他字段
'attachment' => [
'original_name' => $_FILES['file']['name'],
'stored_name' => $stored_name,
'mime_type' => $_FILES['file']['type'],
'size_bytes' => $_FILES['file']['size']
]
];
file_put_contents("data/{$id}.json", json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
2.3 路径与目录权限的实战配置
uploads/和data/目录的权限设置,是部署时最容易出错的环节。很多新手上传失败,报错Warning: move_uploaded_file(): Unable to move ... Permission denied,根源就在权限。
标准配置(Linux服务器):
- uploads/ 和 data/ 目录:chmod 755(所有者可读写执行,组和其他人可读执行);
- 目录所有者:应为Web服务器进程用户(Apache通常是www-data,Nginx是nginx或www-data),而非root;
- 验证命令:ps aux | grep apache 查看Apache用户,然后sudo chown -R www-data:www-data uploads data。
但755对uploads/仍不够——因为move_uploaded_file()需要写权限。正确做法是chmod 775(组可写),并确保Web服务器用户属于该目录所属组。例如:
# 创建组并添加用户
sudo groupadd webwriters
sudo usermod -a -G webwriters www-data
# 设置目录
sudo chgrp webwriters uploads data
sudo chmod 775 uploads data
Windows环境下(XAMPP)则不同:uploads/目录需右键→属性→安全→编辑→添加Users组→勾选“修改”权限。关键是理解:PHP脚本以Web服务器进程身份运行,它对目录的权限,取决于操作系统对该进程用户的授权,而非你当前登录用户的权限。
实操心得:我曾遇到一个诡异问题——本地XAMPP能上传,但部署到阿里云轻量应用服务器就失败。排查发现是SELinux策略阻止了httpd写入
uploads/。解决命令:sudo setsebool -P httpd_can_network_connect 1和sudo setsebool -P httpd_read_user_content 1。这类问题不会出现在教程里,但生产环境极常见。
2.4 XSS与路径穿越的双重防护实践
input.php接收$_POST['nickname']和$_POST['content'],这两个字段若不做处理,直接写入JSON再在show.php中echo,就会触发XSS。防护必须分两层:
-
入库前过滤(存储层):对
nickname用preg_replace('/[^a-zA-Z0-9_\x{4e00}-\x{9fa5}]/u', '', $_POST['nickname'])剔除非允许字符;对content用strip_tags($_POST['content'])移除HTML标签,再用htmlspecialchars()转义剩余符号。注意:strip_tags()不能替代htmlspecialchars(),因为<script>被移除后,<script>仍需转义以防二次注入。 -
输出时转义(展示层):
show.php中echo htmlentities($data['content'], ENT_QUOTES, 'UTF-8');。ENT_QUOTES确保单双引号都被转义,UTF-8指定编码,避免乱码。
路径穿越防护集中在show.php和delete.php的ID校验:
$id = $_GET['id'] ?? '';
if (!preg_match('/^\d{8}_\d{6}_\d+$/', $id)) {
die('Invalid ID format');
}
$file_path = 'data/' . $id . '.json';
if (!is_file($file_path) || !is_readable($file_path)) {
die('File not found or inaccessible');
}
正则/^\d{8}_\d{6}_\d+$/严格限定ID只能是数字+下划线,彻底堵死../../../etc/passwd类攻击。同时is_file()和is_readable()双重校验,比单纯file_exists()更安全(防止符号链接攻击)。
3. 实操过程与核心环节实现
3.1 input.php:表单构建与前端约束
input.php是用户第一接触点,其HTML结构直接影响数据质量。完整代码如下(关键注释已嵌入):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>留言板 - 提交</title>
<style>
body { font-family: "Helvetica Neue", sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input, textarea, select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
textarea { height: 120px; resize: vertical; }
.btn { background: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
.btn:hover { background: #0056b3; }
.required { color: red; }
</style>
</head>
<body>
<h1>📝 留言板 - 提交新留言</h1>
<!-- 表单action指向自身,method为POST -->
<form action="input.php" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label for="nickname">网名 <span class="required">*</span></label>
<input type="text" id="nickname" name="nickname" required
placeholder="请输入2-10个字符(支持中文)"
maxlength="10"
pattern="^[a-zA-Z0-9_\u4e00-\u9fa5]{2,10}$"
title="仅允许字母、数字、下划线、中文,长度2-10">
</div>
<div class="form-group">
<label for="title">主题 <span class="required">*</span></label>
<input type="text" id="title" name="title" required
placeholder="一句话概括留言内容"
maxlength="50">
</div>
<div class="form-group">
<label for="content">留言内容 <span class="required">*</span></label>
<textarea id="content" name="content" required
placeholder="请输入您的留言...(支持换行)"></textarea>
</div>
<div class="form-group">
<label for="attachment">附件(可选)</label>
<input type="file" id="attachment" name="file"
accept="image/*,.pdf,.doc,.docx,.txt">
<p style="font-size: 12px; color: #666;">支持图片、PDF、Word、文本文件,最大5MB</p>
</div>
<button type="submit" class="btn">提交留言</button>
</form>
<?php
// 表单提交处理逻辑(实际应分离到process_submit.php,此处为演示合并)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 引入处理脚本
require_once 'process_submit.php';
}
?>
</body>
</html>
关键细节说明:
- enctype="multipart/form-data"是文件上传必需属性,缺失则$_FILES为空;
- pattern属性提供前端实时校验,但绝不依赖它——后端必须重新校验,因为浏览器可禁用JS绕过;
- accept="image/*,.pdf,.doc,.docx,.txt"限制文件类型选择框,提升用户体验,但同样不具安全性;
- maxlength和placeholder是友好的交互提示,减少无效提交。
3.2 process_submit.php:数据清洗、附件处理与JSON写入
这是整个系统的“心脏”,代码必须严谨。以下是精简后的核心逻辑(含详细注释):
<?php
// 1. 基础配置
define('UPLOAD_DIR', 'uploads/');
define('DATA_DIR', 'data/');
define('PHOTOS_DIR', 'photos/');
// 2. 创建必要目录(若不存在)
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0775, true);
if (!is_dir(DATA_DIR)) mkdir(DATA_DIR, 0775, true);
// 3. 获取并清洗POST数据
$nickname = trim($_POST['nickname'] ?? '');
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
// 4. 用户名校验:长度2-10,仅允许指定字符
if (empty($nickname) || strlen($nickname) < 2 || strlen($nickname) > 10 ||
!preg_match('/^[a-zA-Z0-9_\x{4e00}-\x{9fa5}]+$/u', $nickname)) {
die('❌ 网名格式错误:请使用2-10位字母、数字、下划线或中文');
}
// 5. 主题与内容校验
if (empty($title) || empty($content)) {
die('❌ 主题和内容不能为空');
}
if (strlen($title) > 50) {
die('❌ 主题长度不能超过50个字符');
}
if (strlen($content) > 2000) {
die('❌ 留言内容不能超过2000个字符');
}
// 6. 处理附件上传(若存在)
$attachment = null;
if (!empty($_FILES['file']['name'])) {
$upload_file = $_FILES['file'];
// 6.1 检查上传错误
if ($upload_file['error'] !== UPLOAD_ERR_OK) {
switch ($upload_file['error']) {
case UPLOAD_ERR_INI_SIZE:
die('❌ 附件大小超过服务器限制(php.ini中upload_max_filesize)');
case UPLOAD_ERR_FORM_SIZE:
die('❌ 附件大小超过表单限制(MAX_FILE_SIZE)');
case UPLOAD_ERR_PARTIAL:
die('❌ 附件上传不完整');
case UPLOAD_ERR_NO_FILE:
die('❌ 未选择附件');
default:
die('❌ 附件上传失败,请重试');
}
}
// 6.2 检查文件大小(5MB上限)
$max_size = 5 * 1024 * 1024; // 5MB
if ($upload_file['size'] > $max_size) {
die('❌ 附件大小不能超过5MB');
}
// 6.3 检查MIME类型(白名单)
$allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain'];
if (!in_array($upload_file['type'], $allowed_types)) {
die('❌ 不支持的文件类型,请上传JPG/PNG/GIF/PDF/DOC/DOCX/TXT');
}
// 6.4 生成安全文件名
$extension = pathinfo($upload_file['name'], PATHINFO_EXTENSION);
$safe_extension = strtolower($extension);
// 防御扩展名伪造:用fileinfo扩展校验真实MIME
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$real_mime = finfo_file($finfo, $upload_file['tmp_name']);
finfo_close($finfo);
$allowed_real_mimes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain'];
if (!in_array($real_mime, $allowed_real_mimes)) {
die('❌ 文件类型检测失败,请上传真实格式的文件');
}
// 6.5 构建存储路径
$timestamp = date('Ymd_His');
$random = mt_rand(100000, 999999);
$stored_name = "{$timestamp}_{$random}_upload.{$safe_extension}";
$upload_path = UPLOAD_DIR . $stored_name;
// 6.6 移动文件
if (!move_uploaded_file($upload_file['tmp_name'], $upload_path)) {
die('❌ 附件保存失败,请检查uploads目录权限');
}
// 6.7 记录附件信息
$attachment = [
'original_name' => $upload_file['name'],
'stored_name' => $stored_name,
'mime_type' => $real_mime,
'size_bytes' => $upload_file['size']
];
}
// 7. 生成唯一ID
$id = date('Ymd_His') . '_' . mt_rand(100000, 999999);
// 8. 构建数据数组
$data = [
'id' => $id,
'nickname' => htmlspecialchars($nickname, ENT_QUOTES, 'UTF-8'),
'title' => htmlspecialchars($title, ENT_QUOTES, 'UTF-8'),
'content' => htmlspecialchars($content, ENT_QUOTES, 'UTF-8'),
'created_at' => date('Y-m-d H:i:s'),
'attachment' => $attachment
];
// 9. 写入JSON文件
$json_content = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$json_file = DATA_DIR . $id . '.json';
if (file_put_contents($json_file, $json_content) === false) {
die('❌ 数据保存失败,请检查data目录权限');
}
// 10. 重定向到成功页面(防止重复提交)
header("Location: success.php?id={$id}");
exit;
这段代码体现了“防御性编程”思想:每一步都有明确的失败处理,错误信息对用户友好(中文提示),且不泄露服务器路径等敏感信息。success.php是一个简单的跳转页,显示“留言提交成功”,并提供返回index.php的链接。
3.3 index.php:留言列表生成与排序逻辑
index.php的核心任务是扫描data/目录,读取所有JSON文件,按时间倒序排列并渲染表格。代码如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>留言板 - 列表</title>
<style>
body { font-family: "Helvetica Neue", sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #eee; }
th { background-color: #f8f9fa; font-weight: bold; }
tr:hover { background-color: #f5f5f5; }
.actions a { margin-right: 10px; text-decoration: none; color: #007bff; }
.actions a:hover { text-decoration: underline; }
.no-data { text-align: center; color: #666; font-style: italic; }
.photo-placeholder { width: 40px; height: 40px; background: #eee; border-radius: 4px; display: inline-block; margin-right: 8px; }
</style>
</head>
<body>
<h1>📋 留言板 - 所有留言</h1>
<p><a href="input.php" class="btn" style="background:#28a745;color:white;padding:8px 16px;text-decoration:none;border-radius:4px;">+ 新建留言</a></p>
<?php
$data_dir = 'data/';
$files = glob($data_dir . '*.json');
// 读取所有JSON文件并解析
$messages = [];
foreach ($files as $file) {
if (is_file($file) && is_readable($file)) {
$content = file_get_contents($file);
// 清除BOM
$content = trim($content, "\xEF\xBB\xBF");
$data = json_decode($content, true);
// 校验JSON有效性
if (json_last_error() === JSON_ERROR_NONE &&
isset($data['id']) && isset($data['created_at'])) {
$messages[] = $data;
} else {
// 记录损坏文件(开发时可写入error.log)
error_log("Corrupted JSON file: {$file}");
}
}
}
// 按created_at倒序排列(最新在前)
usort($messages, function($a, $b) {
return strcmp($b['created_at'], $a['created_at']); // 字符串比较,ISO格式可直接比
});
?>
<?php if (empty($messages)): ?>
<div class="no-data">
<p>暂无留言,快去<a href="input.php">提交第一条</a>吧!</p>
<div style="margin-top:20px;">
<img src="photos/78786.jpg" alt="示例图片" width="120" style="border-radius:4px;">
<p style="margin-top:10px;font-size:14px;color:#666;">这是预置示例图,用于视觉引导</p>
</div>
</div>
<?php else: ?>
<table>
<thead>
<tr>
<th>ID</th>
<th>网名</th>
<th>主题</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($messages as $msg): ?>
<tr>
<td><?php echo htmlspecialchars($msg['id'], ENT_QUOTES, 'UTF-8'); ?></td>
<td><?php echo htmlspecialchars($msg['nickname'], ENT_QUOTES, 'UTF-8'); ?></td>
<td><?php echo htmlspecialchars($msg['title'], ENT_QUOTES, 'UTF-8'); ?></td>
<td><?php echo htmlspecialchars($msg['created_at'], ENT_QUOTES, 'UTF-8'); ?></td>
<td class="actions">
<a href="show.php?id=<?php echo urlencode($msg['id']); ?>">查看</a>
<a href="delete.php?id=<?php echo urlencode($msg['id']); ?>" onclick="return confirm('确定要删除这条留言吗?')">删除</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</body>
</html>
关键点:
- glob($data_dir . '*.json')高效获取所有JSON文件,比scandir()再过滤更简洁;
- usort()使用字符串比较排序created_at,因为ISO格式Y-m-d H:i:s天然支持字典序倒排;
- urlencode()对ID进行URL编码,防止ID含特殊字符(如+、/)导致链接失效;
- onclick="return confirm(...)"提供前端删除确认,是用户体验加分项,但不替代后端校验。
3.4 show.php:详情页的安全渲染与附件处理
show.php是数据展示的最后一环,必须确保输出绝对安全。代码如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>留言板 - 详情</title>
<style>
body { font-family: "Helvetica Neue", sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; }
.message-header { margin-bottom: 25px; padding-bottom: 15px; border-bottom: 1px solid #eee; }
.message-content { line-height: 1.6; }
.attachment { margin-top: 20px; padding: 12px; background: #f8f9fa; border-radius: 4px; }
.attachment a { color: #007bff; text-decoration: none; }
.attachment a:hover { text-decoration: underline; }
.back-link { display: inline-block; margin-top: 20px; color: #007bff; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<?php
$id = $_GET['id'] ?? '';
// 1. ID格式校验
if (!preg_match('/^\d{8}_\d{6}_\d+$/', $id)) {
die('❌ 无效的ID格式');
}
$data_file = 'data/' . $id . '.json';
// 2. 文件存在性与可读性校验
if (!is_file($data_file) || !is_readable($data_file)) {
die('❌ 留言不存在或无法访问');
}
// 3. 读取并解析JSON
$content = file_get_contents($data_file);
$content = trim($content, "\xEF\xBB\xBF"); // 清除BOM
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
die('❌ 数据文件损坏');
}
// 4. 字段完整性校验
$required_fields = ['id', 'nickname', 'title', 'content', 'created_at'];
foreach ($required_fields as $field) {
if (!isset($data[$field])) {
die("❌ 数据缺失字段: {$field}");
}
}
?>
<h1>📄 留言详情</h1>
<div class="message-header">
<h2><?php echo htmlspecialchars($data['title'], ENT_QUOTES, 'UTF-8'); ?></h2>
<p><strong>网名:</strong><?php echo htmlspecialchars($data['nickname'], ENT_QUOTES, 'UTF-8'); ?></p>
<p><strong>时间:</strong><?php echo htmlspecialchars($data['created_at'], ENT_QUOTES, 'UTF-8'); ?></p>
</div>
<div class="message-content">
<p><?php echo nl2br(htmlentities($data['content'], ENT_QUOTES, 'UTF-8')); ?></p>
</div>
<?php if (!empty($data['attachment'])): ?>
<div class="attachment">
<p><strong>📎 附件:</strong></p>
<?php
$att = $data['attachment'];
$upload_path = 'uploads/' . $att['stored_name'];
// 校验附件文件是否存在且可读
if (is_file($upload_path) && is_readable($upload_path)) {
// 尝试获取图片尺寸(防御非图片文件伪装)
if (in_array($att['mime_type'], ['image/jpeg', 'image/png', 'image/gif'])) {
$size_info = @getimagesize($upload_path);
if ($size_info) {
echo '<p><img src="' . htmlspecialchars($upload_path, ENT_QUOTES, 'UTF-8') . '" alt="附件" style="max-width:100%;height:auto;"></p>';
}
}
echo '<p><a href="' . htmlspecialchars($upload_path, ENT_QUOTES, 'UTF-8') . '">⬇ 下载「' . htmlspecialchars($att['original_name'], ENT_QUOTES, 'UTF-8') . '」</a></p>';
echo '<p><small>类型:' . htmlspecialchars($att['mime_type'], ENT_QUOTES, 'UTF-8') . ',大小:' . number_format($att['size_bytes'] / 1024, 2) . ' KB</small></p>';
} else {
echo '<p>⚠ 附件文件已丢失或不可访问</p>';
}
?>
</div>
<?php endif; ?>
<p class="back-link">← <a href="index.php">返回列表</a></p>
</body>
</html>
这里展示了完整的“防御链”:ID校验 → 文件校验 → JSON解析校验 → 字段完整性校验 → 输出转义。特别是附件处理,先检查文件存在,再用getimagesize()验证是否为真实图片(@抑制警告),最后才输出<img>标签,避免XSS和资源加载失败。
3.5 delete.php:后台删除的实现与风险控制
delete.php是管理入口,设计为GET请求,带二次确认。代码极简但关键:
<?php
$id = $_GET['id'] ?? '';
if (!preg_match('/^\d{8}_\d{6}_\d+$/', $id)) {
die('❌ 无效ID');
}
$data_file = 'data/' . $id . '.json';
$upload_file = null;
// 1. 读取JSON获取附件信息
if (is_file($data_file) && is_readable($data_file)) {
$content = file_get_contents($data_file);
$content = trim($content, "\xEF\xBB\xBF");
$data = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE && !empty($data['attachment']['stored_name'])) {
$upload_file = 'uploads/' . $data['attachment']['stored_name'];
}
}
// 2. 执行删除
$result = true;
if (is_file($data_file)) {
$result = unlink($data_file);
}
if ($upload_file && is_file($upload_file)) {
$result = $result && unlink($upload_file);
}
if ($result) {
header("Location: index.php?deleted=1");
} else {
header("Location: index.php?error=delete_failed");
}
exit;
注意:delete.php本身不渲染页面,而是重定向回index.php,并在URL中携带状态参数。index.php需增加相应提示:
<?php if (isset($_GET['deleted']) && $_GET['deleted'] == 1): ?>
<div style="background:#d4edda;color:#155724;padding:10px;border-radius:4px;margin:15px 0;">
✅ 留言删除成功!
</div>
<?php elseif (isset($_GET['error']) && $_GET['error'] == 'delete_failed'): ?>
<div style="background:#f8d7da;color:#721c24;padding:10px;border-radius:4px;margin:15px 0;">
❌ 删除失败,请重试
</div>
<?php endif; ?>
这种“处理-重定向-显示”的模式(PRG Pattern)有效防止用户刷新导致重复删除。
4. 常见问题与排查技巧实录
4.1 上传失败的五大原因及诊断流程
上传功能是新手最易卡壳的环节,以下是实测高频问题清单及排查步骤:
| 现象 | 可能原因 | 诊断命令/方法 | 解决方案 |
|---|---|---|---|
Warning: move_uploaded_file(): Unable to move... | uploads/目录权限不足 | ls -ld uploads 查看权限;ps aux | grep apache 查看Web用户 | sudo chmod 775 uploads; sudo chown -R www-data:www-data uploads |
| 表单提交后空白页 | process_submit.php语法错误或致命错误 | 查看PHP错误日志(/var/log/apache2/error.log或php.ini中error_log路径) | 开启display_errors=On(仅开发环境),定位具体行号 |
附件为空($_FILES为空) | 表单缺少enctype="multipart/form-data" | 查看浏览器开发者工具→Network→Headers,确认Content-Type: multipart/form-data | 在<form>标签中添加该属性 |
| 上传后文件损坏(打不开) | JSON写入时编码错误(BOM头) | hexdump -C data/xxx.json \| head 查看前3字节是否为ef bb bf | 在file_put_contents()前加$content = trim($content, "\xEF\xBB\xBF"); |
| 上传大文件失败(>2MB) | php.ini中upload_max_filesize或post_max_size过小 | php -i \| grep -E "(upload_max_filesize|post_max_size)" | 编辑php.ini,设为upload_max_filesize = 10M,post_max_size = 12M,重启Web服务 |
实操心得:我习惯在
process_submit.php开头加一行error_log("Upload debug: " . print_r($_FILES, true), 3, "upload_debug.log");,把$_FILES完整内容写入日志,这是定位上传问题的最快途径。日志文件会记录临时文件路径、大小、类型,一目了然。
4.2 JSON解析失败的典型场景与修复
json_decode()返回null却不报错,是JSON处理中最隐蔽的坑。常见原因及修复:
- BOM头残留:Windows记事本保存的JSON自带BOM。修复:
$content = trim($content, "\xEF\xBB\xBF"); - 中文编码不一致:JSON文件用GBK保存,但PHP以UTF-8读取。修复:
$content = mb_convert_encoding($content, 'UTF-8', 'auto'); - 尾部逗号:JSON标准不允许对象末尾逗号,但某些编辑器会自动添加。修复:用在线JSON校验工具(如jsonlint.com)检查;
- 单引号代替双引号:JSON规范要求键和字符串必须用双引号。修复:全局替换
'为"; - 特殊字符未转义:如
content字段含未转义的"或\。修复:入库前用json_encode()而非手动拼接。
诊断技巧:在json_decode()后立即加var_dump(json_last_error_msg());,PHP 7.3+支持该函数,能直接输出错误描述,比json_last_error()数字码直观得多。
4.3 跨平台路径兼容性问题
Windows与Linux路径分隔符不同(\ vs /),DIRECTORY_SEPARATOR常被忽略。本系统中所有路径拼接均使用/,因为PHP的file_get_contents()、is_file()等函数在Windows下也兼容正斜杠。但为保险起见,在process_submit.php中定义:
define('DS', DIRECTORY_SEPARATOR);
define('UPLOAD_DIR', 'uploads' . DS);
define('DATA_DIR', 'data' . DS);
然后用UPLOAD_DIR . $stored_name拼接,确保100%跨平台。
4.4 安全加固的进阶建议(非必需但强烈推荐)
虽然本系统定位为入门教学,但实际部署时建议追加以下加固:
- CSRF防护:在
input.php表单中添加隐藏域<input type="hidden" name="token" value="<?php echo bin2hex(random_bytes(32)); ?>">,并在process_submit.php中校验$_SESSION['token']是否匹配; - 速率限制:用
file_put_contents('rate_limit.log', date('Y-m-d H:i:s') . " {$_SERVER['REMOTE_ADDR']}\n", FILE_APPEND);记录IP,每小时超5次提交则拒绝; - 日志审计:所有成功提交、删除操作记录到
audit.log,包含时间、IP、ID、操作类型; - HTTPS强制:在
.htaccess中添加RewriteCond %{HTTPS} off和RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI}。
这些不是“炫技”,而是把“安全是默认选项”这一理念,从第一天就植入开发习惯。
最后分享一个小技巧:每次更新代码后,我都会用
find . -name "*.php" -exec php -l {} \;批量检查所有PHP文件语法,-l参数表示lint模式,能快速发现Parse error。这比等用户报错再排查高效十倍。
这个纯PHP留言板,表面看只是三页代码,但每一行都在回答一个根本问题:“当用户把不可控的数据交到你手上时,你凭什么相信它?”答案不是靠运气,而是靠层层校验、主动防御、日志追踪和持续测试。它不追求功能丰富,但力求每一步都经得起推敲。如果你能把它跑通、改通、debug通,那么下一个用MySQL或Redis重构它的任务,对你来说就只是换把钥匙开门而已——门后的世界,早已在你心里画好了地图。
简介:一套开箱即用的PHP留言板系统,不依赖数据库,所有数据以文件形式保存在服务器本地。用户通过input.php填写网名、主题、正文并可上传图片等附件;提交后自动跳转至index.php,以简洁表格形式展示所有留言条目,含ID、时间、主题和操作入口;点击任意条目进入show.php查看完整信息,包括原始提交内容、时间戳及附件下载链接;delete.php提供后台删除功能,需手动访问触发(无权限校验);上传文件统一存放在uploads目录,photos目录内置示例图(如78786.jpg)便于快速测试;.gitignore和.inscode为开发环境配置文件;整个结构扁平清晰,适合PHP入门者学习表单接收、文件写入、页面跳转与基础安全意识,部署时只需将全部文件放入支持PHP解析的Web目录即可运行。

1004

被折叠的 条评论
为什么被折叠?



