
网站留言板代码示例:实现留言发布与删除功能
留言板看起来只是一个文本框、一个发布按钮和一列留言,真正上线时却会遇到一串实际问题:用户能提交多长的内容?删除操作如何确认权限?输入的 HTML 要不要执行?多人同时访问时数据放在哪里?如果只把留言追加到一个文本文件,短期可能跑得起来,但并发写入、数据校验和权限控制都会很快变得棘手。
本文用 Node.js、Express 和 SQLite 实现一个可运行的最小留言板。功能包括查看留言、发布留言和凭删除密钥删除指定留言。示例把留言正文当作纯文本处理,不允许发布 HTML;删除密钥只在发布成功时返回一次,数据库只保存密钥摘要。这个设计适合小型网站、内部工具或功能原型,也能帮助理解留言板的关键安全边界。它不是完整的生产级社区系统,文末会说明需要补充的部分。
一、适用场景与设计约定
示例采用以下技术和约定:
- Node.js 与 Express负责提供网页和 JSON 接口。
- SQLite保存留言,适用于单机应用、小型站点及原型项目,不需要单独部署数据库服务器。
- 留言正文是纯文本,长度限制为 1 至 1000 个 Unicode 码点。服务端会再次校验,不能只依赖浏览器的输入限制。
- 发布成功后,服务端生成随机删除密钥,并且只在响应中返回一次。数据库保存密钥的 SHA-256 摘要;用户需要保留原密钥才能删除对应留言。
- 每条留言最多只能凭对应密钥删除。此机制是持有者凭证,不代表已经验证了留言者的真实身份。
为什么不直接提供“输入留言编号就能删除”的接口?因为编号通常会出现在公开页面上。若没有额外权限检查,任何访问者都能删除别人的留言。本文通过删除密钥建立一项简单的能力凭证:知道对应密钥的人可以删除留言。密钥丢失后,用户无法自行恢复;这对不需要账号系统的小型示例较简单,但不能替代正式的登录、角色权限或管理员审核。
二、创建项目
需要安装 Node.js 18 或更高版本。建立项目目录后初始化 npm,并安装 Express、better-sqlite3 和 express-rate-limit:
mkdir guestbook
cd guestbook
npm init -y
npm install express better-sqlite3 express-rate-limit
项目文件结构如下:
guestbook/
server.js
public/
index.html
data/
data目录可由程序创建,SQLite 数据库文件会放在该目录中。请确认运行 Node.js 服务的系统用户对该目录具有读写权限。
三、实现后端接口
在项目根目录创建 server.js。后端提供三个接口:GET /api/messages用于读取留言,POST /api/messages用于发布留言,DELETE /api/messages/:id用于删除留言。发布和删除接口都设有简单的进程内频率限制,用于降低最基本的重复请求风险。
const express = require('express');
const Database = require('better-sqlite3');
const rateLimit = require('express-rate-limit');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const app = express();
const port = Number(process.env.PORT) || 3000;
const dataDir = path.join(__dirname, 'data');
fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'guestbook.sqlite'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author TEXT NOT NULL,
body TEXT NOT NULL,
delete_key_hash TEXT NOT NULL,
created_at TEXT NOT NULL
)
`);
app.disable('x-powered-by');
app.use(express.json({ limit: '10kb' }));
const publishLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: '请求过于频繁,请稍后再试。' }
});
const deleteLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 20,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: '请求过于频繁,请稍后再试。' }
});
function codePointLength(value) {
return Array.from(value).length;
}
function isValidText(value, maxLength) {
return typeof value === 'string'
&& value.trim().length > 0
&& codePointLength(value) <= maxLength;
}
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function safeEqualHex(a, b) {
if (!/^[a-f0-9]{64}$/i.test(a) || !/^[a-f0-9]{64}$/i.test(b)) {
return false;
}
return crypto.timingSafeEqual(
Buffer.from(a, 'hex'),
Buffer.from(b, 'hex')
);
}
app.use(express.static(path.join(__dirname, 'public')));
app.get('/api/messages', (req, res) => {
const rows = db.prepare(`
SELECT id, author, body, created_at AS createdAt
FROM messages
ORDER BY id DESC
LIMIT 100
`).all();
res.json({ messages: rows });
});
app.post('/api/messages', publishLimiter, (req, res) => {
const author = typeof req.body.author === 'string'
? req.body.author.trim()
: '';
const body = typeof req.body.body === 'string'
? req.body.body.trim()
: '';
if (!isValidText(author, 40)) {
return res.status(400).json({
error: '昵称不能为空,且不能超过 40 个 Unicode 码点。'
});
}
if (!isValidText(body, 1000)) {
return res.status(400).json({
error: '留言不能为空,且不能超过 1000 个 Unicode 码点。'
});
}
const deleteKey = crypto.randomBytes(32).toString('base64url');
const createdAt = new Date().toISOString();
const result = db.prepare(`
INSERT INTO messages (author, body, delete_key_hash, created_at)
VALUES (?, ?, ?, ?)
`).run(author, body, sha256(deleteKey), createdAt);
res.status(201).json({
message: {
id: Number(result.lastInsertRowid),
author,
body,
createdAt
},
deleteKey
});
});
app.delete('/api/messages/:id', deleteLimiter, (req, res) => {
const id = Number(req.params.id);
const deleteKey = req.get('X-Delete-Key');
if (!Number.isSafeInteger(id) || id <= 0) {
return res.status(400).json({ error: '留言编号无效。' });
}
if (typeof deleteKey !== 'string' || deleteKey.length > 100) {
return res.status(401).json({ error: '删除密钥无效。' });
}
const row = db.prepare(`
SELECT delete_key_hash FROM messages WHERE id = ?
`).get(id);
if (!row || !safeEqualHex(row.delete_key_hash, sha256(deleteKey))) {
return res.status(404).json({
error: '留言不存在,或删除密钥不正确。'
});
}
db.prepare('DELETE FROM messages WHERE id = ?').run(id);
res.status(204).end();
});
app.listen(port, () => {
console.log(`留言板运行于 http://localhost:${port}`);
});
几点值得留意:
express.json({ limit: '10kb' })限制请求体大小。它与正文的 1000 码点限制作用不同:前者限制整个 JSON 请求的字节数,后者限制字段内容长度。即使请求体很小,也仍需做字段校验。Array.from(value).length按 Unicode 码点计数,比 JavaScript 字符串的.length更接近用户看到的字符数。不过它仍不等于“屏幕上显示的字形数”:组合字符、肤色修饰符和部分 emoji 序列可能由多个码点组成。- 插入和查询使用带占位符的 SQL,避免把用户输入拼进 SQL 语句。昵称和留言仍然是用户提供的数据,数据库参数化并不能代替输出时的 HTML 转义。
- 删除密钥有 32 个随机字节,安全强度来自足够的随机性。服务端对密钥计算摘要后保存,不需要把原始密钥写入数据库。该值是随机高熵密钥,不是用户设置的低熵密码,所以这里使用 SHA-256;普通密码不应这样直接存储,应使用专门的密码哈希算法。
- 示例限制了错误响应,不会区分“留言不存在”和“密钥错误”,减少额外暴露信息。接口返回
404并不意味着请求者已通过权限验证。
四、创建前端页面
在 public目录中创建 index.html。页面通过同源接口加载和提交留言;昵称、留言正文以及接口返回的错误信息都使用 textContent写入 DOM,而不是通过 innerHTML解析为 HTML。这样,用户提交类似 <script>的字符串时,会显示为文字而不是执行脚本。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>网站留言板</title>
<style>
body {
max-width: 760px;
margin: 40px auto;
padding: 0 16px;
font: 16px/1.6 system-ui, sans-serif;
color: #222;
}
label { display: block; margin-top: 12px; }
input, textarea, button {
box-sizing: border-box;
max-width: 100%;
padding: 8px;
font: inherit;
}
input, textarea { width: 100%; }
textarea { min-height: 120px; }
li { margin: 16px 0; }
.meta { color: #666; font-size: 0.9em; }
.message-body { white-space: pre-wrap; overflow-wrap: anywhere; }
#status { min-height: 1.5em; }
</style>
</head>
<body>
<h1>留言板</h1>
<form id="message-form">
<label for="author">昵称(最多 40 个码点)</label>
<input id="author" name="author" maxlength="40" required>
<label for="body">留言(最多 1000 个码点)</label>
<textarea id="body" name="body" maxlength="1000" required></textarea>
<button type="submit">发布留言</button>
</form>
<p id="status" role="status" aria-live="polite"></p>
<h2>最新留言</h2>
<ol id="messages"></ol>
<script>
const form = document.querySelector('#message-form');
const list = document.querySelector('#messages');
const status = document.querySelector('#status');
function setStatus(text) {
status.textContent = text;
}
function formatDate(value) {
const date = new Date(value);
return Number.isNaN(date.getTime())
? '时间未知'
: date.toLocaleString();
}
function renderMessage(message) {
const item = document.createElement('li');
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent =
message.author + ' · ' + formatDate(message.createdAt);
const body = document.createElement('div');
body.className = 'message-body';
body.textContent = message.body;
const controls = document.createElement('div');
const keyInput = document.createElement('input');
keyInput.type = 'password';
keyInput.autocomplete = 'off';
keyInput.placeholder = '删除密钥';
keyInput.setAttribute('aria-label', '删除密钥');
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.textContent = '删除';
deleteButton.addEventListener('click', async () => {
const key = keyInput.value.trim();
if (!key) {
setStatus('请输入发布时保存的删除密钥。');
return;
}
deleteButton.disabled = true;
try {
const response = await fetch(
'/api/messages/' + encodeURIComponent(message.id),
{
method: 'DELETE',
headers: { 'X-Delete-Key': key }
}
);
if (!response.ok) {
const result = await response.json().catch(() => ({}));
setStatus(result.error || '删除失败。');
return;
}
item.remove();
setStatus('留言已删除。');
} catch {
setStatus('网络错误,删除未完成。');
} finally {
deleteButton.disabled = false;
}
});
controls.append(keyInput, deleteButton);
item.append(meta, body, controls);
return item;
}
async function loadMessages() {
try {
const response = await fetch('/api/messages');
if (!response.ok) throw new Error('加载失败');
const result = await response.json();
list.replaceChildren(...result.messages.map(renderMessage));
} catch {
setStatus('留言加载失败,请刷新页面重试。');
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
setStatus('');
const data = new FormData(form);
const payload = {
author: data.get('author'),
body: data.get('body')
};
try {
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
setStatus(result.error || '发布失败。');
return;
}
list.prepend(renderMessage(result.message));
setStatus(
'发布成功。请立即保存本条留言的删除密钥:' + result.deleteKey
);
form.reset();
} catch {
setStatus('网络错误,留言未能发布。');
}
});
loadMessages();
</script>
</body>
</html>
前端的 maxlength能改善填写体验,但不是安全边界。攻击者可以绕过页面,直接向接口发送请求,因此服务端必须独立验证数据。前端也没有保存删除密钥到浏览器存储中:发布成功后密钥只显示在提示文字里,用户应把它保存在可靠的位置。若刷新页面或丢失密钥,示例不提供找回方式。
五、启动与验证
在项目根目录运行:
node server.js
浏览器访问 http://localhost:3000,填写昵称和留言后发布。成功时页面会显示删除密钥;把密钥复制保存,然后在对应留言的“删除密钥”输入框中粘贴并点击“删除”。如果密钥错误,页面会显示删除失败提示,留言不会被移除。
也可以使用命令行检查读取接口:
curl -i http://localhost:3000/api/messages
发布接口要求 JSON 请求体。下面的命令展示请求格式;实际发布后,响应 JSON 中的 deleteKey是一次性展示的密钥,请妥善保存:
curl -i http://localhost:3000/api/messages \
-H 'Content-Type: application/json' \
-d '{"author":"小林","body":"这是第一条留言。"}'
删除时使用响应中的留言编号和密钥:
curl -i -X DELETE http://localhost:3000/api/messages/1 \
-H 'X-Delete-Key: 将发布响应中的密钥填在这里'
成功删除返回 HTTP 204,响应没有正文。执行前应把示例编号和密钥替换为真实值;密钥不应放进公开的脚本、代码仓库或日志中。
六、参数限制与安全边界
输入限制不是防垃圾机制
昵称最多 40 个码点、正文最多 1000 个码点,主要用于控制单条记录的规模和改善展示效果。它们不会阻止自动化程序频繁发帖,也不能证明内容真实或友善。示例的频率限制是每个 Node.js 进程中的内存计数器:服务重启后计数会重置;部署多个实例时,各实例的计数互不共享;在反向代理后,客户端 IP 识别还需要正确配置代理信任。实际站点通常需要共享的限流存储,并结合验证码、账号信誉、内容审核或人工处理。
纯文本展示仍需要安全地写入页面
将内容作为纯文本保存,并不自动保证网页安全。关键在于输出位置:本文使用 DOM 的 textContent,浏览器不会把内容解释成 HTML。不要为了换行或格式方便,直接把留言拼进 innerHTML。如果未来要允许富文本,应使用经过维护的 HTML 清理库,并根据允许的标签、属性和链接协议制定白名单;仅仅替换几个字符并不是可靠的 HTML 清理方案。
示例没有实现 CSP、HTTPS 配置、跨站请求伪造(CSRF)防护策略、账号系统或管理员权限。当前接口是同源网页使用的简单原型,部署到公开网站前应评估这些风险。特别是若以后加入基于 Cookie 的登录态,修改数据的接口需要合适的 CSRF 防护;若使用反向代理和自定义域名,也要明确允许的来源与代理配置。HTTPS 能保护传输过程中的密钥,但不能弥补服务器端权限校验或脚本注入问题。
删除密钥是能力凭证,不是身份认证
持有删除密钥即可删除相应留言。因此密钥泄露意味着任何拿到密钥的人都可能删除留言。不要把密钥写进公开页面、分析平台事件或普通访问日志。示例中它通过自定义请求头提交,浏览器不会自动把它附加到其他站点的请求中;但这并不意味着可以忽略整个应用的跨站风险,也不适用于需要复杂账号管理的场景。正式产品通常会把删除权限绑定到经过认证的用户,或由管理员审批。
数据库与备份需要纳入运维
SQLite 数据库文件保存在本地磁盘。程序重启后数据仍然存在,但容器或临时磁盘被销毁时,数据也可能丢失。需要持久化挂载、定期备份,并验证备份可以恢复。WAL 模式有助于数据库的并发读写行为,但不等同于多台服务器共享同一个数据库文件的部署方案。若应用需要多实例部署、高写入量或更复杂的运维能力,应评估 PostgreSQL 等服务端数据库,并规划迁移、备份和权限管理。
七、常见错误
- 只在浏览器检查长度。客户端校验可以被绕过。服务端必须校验字段类型、空值和长度;请求体本身也应有大小上限。
- 将用户内容直接塞入 HTML 字符串。这可能导致跨站脚本攻击。展示纯文本优先使用
textContent,不要用innerHTML处理留言。 - 用留言编号作为删除权限。编号通常不是秘密。删除接口还必须校验登录用户、管理员角色或每条记录对应的凭证。
- 把删除密钥明文保存到数据库。本示例只存 SHA-256 摘要。数据库泄露时,攻击者不能直接读取原始密钥;不过应用日志、前端脚本和网络传输仍要妥善保护。
- 认为频率限制已经解决垃圾留言。进程内限流只能提供基础限制,无法单独抵御分布式请求、代理识别错误或内容滥用。公开服务需要更完整的反滥用策略。
- 忘记部署目录权限。SQLite 需要写入数据库文件,WAL 模式还可能生成辅助文件。运行服务的账户必须能访问数据目录。
- 依赖浏览器保存删除密钥却没有恢复方案。本文要求用户发布后自行保存密钥。产品应明确告知密钥只显示一次,或改为账号绑定的删除流程。
八、从示例走向正式功能
这个留言板适合演示“发布—读取—删除”的完整流程,也适合低流量、单机运行的内部页面。上线前至少应检查:是否启用 HTTPS,数据库文件是否持久化并定期备份,是否需要账号或管理员审核,如何处理垃圾内容和举报,日志是否会意外记录删除密钥,以及代理、限流和错误监控是否配置正确。
当需求扩展到编辑留言、回复、分页、审核状态或多用户身份时,建议先明确数据模型和权限规则,再扩展接口。例如,登录用户删除自己的留言,与管理员删除任意留言,应该是两种经过服务端授权的操作,而不是依赖前端隐藏按钮。把输入校验、数据库操作和权限判断放在服务端,并让页面始终以安全的方式呈现用户内容,才是留言板能够持续维护的基础。









