纯PHP文件存储留言板:支持留言提交、列表浏览、详情查看与附件上传

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:一套开箱即用的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-fileinfoext-json,文件+JSON方案兼容性更广,连某些嵌入式Linux设备(如树莓派轻量Web服务)都能跑。

1.2 三页模型的职责划分与数据流闭环

整个系统由input.phpindex.phpshow.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,提取nicknametitlecontentcreated_atattachment等字段;
    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_namestored_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就能看清全部字段。

  • 扩展性预留:今天只有nicknametitlecontentattachment,明天可能加ip_addressuser_agentis_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是nginxwww-data),而非root
- 验证命令:ps aux | grep apache 查看Apache用户,然后sudo chown -R www-data:www-data uploads data

755uploads/仍不够——因为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 1sudo setsebool -P httpd_read_user_content 1。这类问题不会出现在教程里,但生产环境极常见。

2.4 XSS与路径穿越的双重防护实践

input.php接收$_POST['nickname']$_POST['content'],这两个字段若不做处理,直接写入JSON再在show.phpecho,就会触发XSS。防护必须分两层:

  • 入库前过滤(存储层):对nicknamepreg_replace('/[^a-zA-Z0-9_\x{4e00}-\x{9fa5}]/u', '', $_POST['nickname'])剔除非允许字符;对contentstrip_tags($_POST['content'])移除HTML标签,再用htmlspecialchars()转义剩余符号。注意:strip_tags()不能替代htmlspecialchars(),因为<script>被移除后,&lt;script&gt;仍需转义以防二次注入。

  • 输出时转义(展示层)show.phpecho htmlentities($data['content'], ENT_QUOTES, 'UTF-8');ENT_QUOTES确保单双引号都被转义,UTF-8指定编码,避免乱码。

路径穿越防护集中在show.phpdelete.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"限制文件类型选择框,提升用户体验,但同样不具安全性;
- maxlengthplaceholder是友好的交互提示,减少无效提交。

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">&larr; <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.logphp.inierror_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 bffile_put_contents()前加$content = trim($content, "\xEF\xBB\xBF");
上传大文件失败(>2MB)php.iniupload_max_filesizepost_max_size过小php -i \| grep -E "(upload_max_filesize|post_max_size)"编辑php.ini,设为upload_max_filesize = 10Mpost_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} offRewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI}

这些不是“炫技”,而是把“安全是默认选项”这一理念,从第一天就植入开发习惯。

最后分享一个小技巧:每次更新代码后,我都会用find . -name "*.php" -exec php -l {} \;批量检查所有PHP文件语法,-l参数表示lint模式,能快速发现Parse error。这比等用户报错再排查高效十倍。

这个纯PHP留言板,表面看只是三页代码,但每一行都在回答一个根本问题:“当用户把不可控的数据交到你手上时,你凭什么相信它?”答案不是靠运气,而是靠层层校验、主动防御、日志追踪和持续测试。它不追求功能丰富,但力求每一步都经得起推敲。如果你能把它跑通、改通、debug通,那么下一个用MySQL或Redis重构它的任务,对你来说就只是换把钥匙开门而已——门后的世界,早已在你心里画好了地图。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:一套开箱即用的PHP留言板系统,不依赖数据库,所有数据以文件形式保存在服务器本地。用户通过input.php填写网名、主题、正文并可上传图片等附件;提交后自动跳转至index.php,以简洁表格形式展示所有留言条目,含ID、时间、主题和操作入口;点击任意条目进入show.php查看完整信息,包括原始提交内容、时间戳及附件下载链接;delete.php提供后台删除功能,需手动访问触发(无权限校验);上传文件统一存放在uploads目录,photos目录内置示例图(如78786.jpg)便于快速测试;.gitignore和.inscode为开发环境配置文件;整个结构扁平清晰,适合PHP入门者学习表单接收、文件写入、页面跳转与基础安全意识,部署时只需将全部文件放入支持PHP解析的Web目录即可运行。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值