From ca422df0c39fe1dc4e7031ea856ec2cf717b5718 Mon Sep 17 00:00:00 2001 From: hanyixuanten Date: Sun, 25 Oct 2026 12:13:11 +0000 Subject: [PATCH] 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 --- functions/api/messages.js | 11 +++++++---- functions/api/submit.ts | 39 ++++++++++++++++++++++++++++++++++++++- schema.sql | 19 +++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 schema.sql diff --git a/functions/api/messages.js b/functions/api/messages.js index ce6bee6..cddd739 100644 --- a/functions/api/messages.js +++ b/functions/api/messages.js @@ -2,7 +2,7 @@ * Cloudflare Pages Functions * GET /api/messages → 返回最新留言列表 * POST /api/messages → 新增一条留言 - * 依赖绑定:环境变量 DB(D1 数据库) + * 依赖绑定:环境变量 DB(D1 数据库,建表语句见 /schema.sql) */ const MAX_NAME = 12; @@ -103,6 +103,9 @@ export async function onRequestPost({ request, env }) { 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 { @@ -130,10 +133,10 @@ export async function onRequestPost({ request, env }) { const time = today(); const res = await env.DB .prepare( - `INSERT INTO messages (name, text, time, ip, created_at) - VALUES (?, ?, ?, ?, ?)` + `INSERT INTO messages (name, text, time, ip, ua, country, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` ) - .bind(name, text, time, ip, now) + .bind(name, text, time, ip, ua, country, now) .run(); // 5. 顺手裁剪:只保留最近 500 条,避免无限增长 diff --git a/functions/api/submit.ts b/functions/api/submit.ts index b1bb4c3..a823562 100644 --- a/functions/api/submit.ts +++ b/functions/api/submit.ts @@ -153,6 +153,38 @@ function renderMarkdown(f: { return `${fm.join('\n')}\n\n${f.body.trim()}\n`; } +// ---------- 请求方环境信息(仅写入 PR 描述,不写入仓库文件) ---------- +interface SubmitMeta { + ip: string; + ua: string; + country: string; + time: string; +} + +function requestMeta(request: Request): SubmitMeta { + const fwd = request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim(); + return { + ip: request.headers.get('CF-Connecting-IP') ?? fwd ?? '', + ua: (request.headers.get('User-Agent') ?? '').slice(0, 400), + country: request.headers.get('CF-IPCountry') ?? '', + time: new Date().toISOString(), + }; +} + +function metaSection(m: SubmitMeta): string { + return [ + '
', + '提交环境信息(仅记录于 PR,未写入仓库文件)', + '', + `- **IP:** \`${m.ip || '未知'}\``, + `- **User-Agent:** \`${m.ua || '未知'}\``, + `- **国家/地区:** \`${m.country || '未知'}\``, + `- **提交时间:** \`${m.time}\``, + '', + '
', + ].join('\n'); +} + // ---------- 音乐投稿 ---------- function renderSongJson(f: { title: string; artist: string; link?: string; @@ -171,6 +203,7 @@ function renderSongJson(f: { async function handleSongSubmit( env: Env, fd: FormData, json: (data: unknown, status?: number) => Response, + meta: SubmitMeta, ): Promise { const title = (fd.get('title') as string | null)?.trim() ?? ''; const artist = (fd.get('artist') as string | null)?.trim() ?? ''; @@ -259,6 +292,7 @@ async function handleSongSubmit( '', message ? `**留言:**\n\n${message}` : '', '', + metaSection(meta), '---', '由音乐投稿表单自动创建,请审核后合并。', ].filter(Boolean).join('\n'); @@ -288,6 +322,8 @@ export const onRequestPost: PagesFunction = async ({ request, env }) => { headers: { 'content-type': 'application/json; charset=utf-8' }, }); + const meta = requestMeta(request); + try { // 1) 解析 multipart const ct = request.headers.get('content-type') || ''; @@ -314,7 +350,7 @@ export const onRequestPost: PagesFunction = async ({ request, env }) => { // 1.7) 音乐投稿分支(type=music) const submitType = (fd.get('type') as string | null)?.trim() ?? 'chronicle'; if (submitType === 'music') { - return await handleSongSubmit(env, fd, json); + return await handleSongSubmit(env, fd, json, meta); } const title = (fd.get('title') as string | null)?.trim() ?? ''; @@ -449,6 +485,7 @@ export const onRequestPost: PagesFunction = async ({ request, env }) => { '', message ? `**留言:**\n\n${message}` : '', '', + metaSection(meta), '---', '由 Astro 投稿表单自动创建,请审核后合并。', ].filter(Boolean).join('\n'); diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..97642c0 --- /dev/null +++ b/schema.sql @@ -0,0 +1,19 @@ +-- D1 数据库结构(留言墙 /api/messages 使用,绑定名 DB) +-- +-- 全新部署:直接执行下面的 CREATE TABLE。 +-- 已有部署:老表缺少 ua / country 列时,执行文件末尾注释里的 ALTER TABLE。 + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, -- 留言人昵称 + text TEXT NOT NULL, -- 留言内容 + time TEXT NOT NULL, -- 展示用日期 YYYY.MM.DD + ip TEXT, -- 访客 IP(限流 + 审计) + ua TEXT, -- User-Agent(审计) + country TEXT, -- 国家/地区代码,来自 CF-IPCountry(审计) + created_at INTEGER NOT NULL -- Unix 秒,排序与限流用 +); + +-- 已有部署的增量迁移: +-- ALTER TABLE messages ADD COLUMN ua TEXT; +-- ALTER TABLE messages ADD COLUMN country TEXT;