Files
class-history-template/functions/api/messages.js
T
hanyixuanten ca422df0c3 feat(api): record submitter IP/UA for messages and submissions
- messages: store ua/country alongside ip (audit only, not returned by GET);
  full schema now documented in schema.sql
- submit: append IP/UA/country/time to PR body in a collapsed section;
  never written into committed files
2026-10-25 12:13:11 +00:00

172 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cloudflare Pages Functions
* GET /api/messages → 返回最新留言列表
* POST /api/messages → 新增一条留言
* 依赖绑定:环境变量 DB(D1 数据库,建表语句见 /schema.sql)
*/
const MAX_NAME = 12;
const MAX_TEXT = 120;
const MAX_LIST = 200; // 列表最多返回多少条
const COOLDOWN = 20; // 同一 IP 两条留言的最小间隔(秒)
/* ---------- 工具 ---------- */
function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
// 如果前端和接口不同域,可加上 CORS 头;同域可删掉
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
});
}
function today() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}.${p(d.getMonth() + 1)}.${p(d.getDate())}`;
}
/* 清理文本:去掉控制字符、折叠多余空白 */
function clean(str, max) {
return String(str ?? '')
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, max);
}
/* ---------- OPTIONS(预检) ---------- */
export async function onRequestOptions() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400'
}
});
}
/* ---------- GET:拉取列表 ---------- */
export async function onRequestGet({ env }) {
if (!env.DB) return json({ ok: false, error: '数据库未绑定' }, 500);
try {
const { results } = await env.DB
.prepare(
`SELECT id, name, text, time
FROM messages
ORDER BY created_at DESC, id DESC
LIMIT ?`
)
.bind(MAX_LIST)
.all();
return json({ ok: true, data: results ?? [] });
} catch (err) {
console.error('[GET /api/messages]', err);
return json({ ok: false, error: '读取失败,请稍后再试' }, 500);
}
}
/* ---------- POST:新增留言 ---------- */
export async function onRequestPost({ request, env }) {
if (!env.DB) return json({ ok: false, error: '数据库未绑定' }, 500);
// 1. 解析请求体
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: '请求格式错误' }, 400);
}
// 2. 校验内容
const name = clean(body.name, MAX_NAME) || '匿名同学';
const text = clean(body.text, MAX_TEXT);
if (!text) {
return json({ ok: false, error: '内容不能为空' }, 400);
}
if (String(body.text ?? '').length > 500) {
return json({ ok: false, error: '内容过长' }, 400);
}
// 3. 简单限流:同一 IP 冷却时间内只能发一条
const ip =
request.headers.get('CF-Connecting-IP') ||
request.headers.get('X-Forwarded-For') ||
'';
// 审计信息:入库保存,但列表接口只返回 id/name/text/time,不会外泄
const ua = (request.headers.get('User-Agent') || '').slice(0, 400);
const country = request.headers.get('CF-IPCountry') || '';
const now = Math.floor(Date.now() / 1000);
try {
if (ip) {
const last = await env.DB
.prepare(
`SELECT created_at FROM messages
WHERE ip = ?
ORDER BY created_at DESC
LIMIT 1`
)
.bind(ip)
.first();
if (last && now - last.created_at < COOLDOWN) {
const wait = COOLDOWN - (now - last.created_at);
return json(
{ ok: false, error: `发得太快啦,请等 ${wait} 秒` },
429
);
}
}
// 4. 写入数据库
const time = today();
const res = await env.DB
.prepare(
`INSERT INTO messages (name, text, time, ip, ua, country, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.bind(name, text, time, ip, ua, country, now)
.run();
// 5. 顺手裁剪:只保留最近 500 条,避免无限增长
// 如果不需要可以删掉这一段
try {
await env.DB
.prepare(
`DELETE FROM messages
WHERE id NOT IN (
SELECT id FROM messages
ORDER BY created_at DESC, id DESC
LIMIT 500
)`
)
.run();
} catch (e) {
console.warn('[cleanup]', e);
}
return json({
ok: true,
data: {
id: res.meta?.last_row_id ?? null,
name,
text,
time
}
});
} catch (err) {
console.error('[POST /api/messages]', err);
return json({ ok: false, error: '保存失败,请稍后再试' }, 500);
}
}