mirror of
https://github.com/Vb-Lg/class-history-template.git
synced 2026-11-03 18:46:40 +00:00
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
This commit is contained in:
1 parent
106e4b76b2
commit
ca422df0c3
3 files changed
+64
-5
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
* Cloudflare Pages Functions
|
* Cloudflare Pages Functions
|
||||||
* GET /api/messages → 返回最新留言列表
|
* GET /api/messages → 返回最新留言列表
|
||||||
* POST /api/messages → 新增一条留言
|
* POST /api/messages → 新增一条留言
|
||||||
* 依赖绑定:环境变量 DB(D1 数据库)
|
* 依赖绑定:环境变量 DB(D1 数据库,建表语句见 /schema.sql)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const MAX_NAME = 12;
|
const MAX_NAME = 12;
|
||||||
@@ -103,6 +103,9 @@ export async function onRequestPost({ request, env }) {
|
|||||||
request.headers.get('CF-Connecting-IP') ||
|
request.headers.get('CF-Connecting-IP') ||
|
||||||
request.headers.get('X-Forwarded-For') ||
|
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);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -130,10 +133,10 @@ export async function onRequestPost({ request, env }) {
|
|||||||
const time = today();
|
const time = today();
|
||||||
const res = await env.DB
|
const res = await env.DB
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO messages (name, text, time, ip, created_at)
|
`INSERT INTO messages (name, text, time, ip, ua, country, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.bind(name, text, time, ip, now)
|
.bind(name, text, time, ip, ua, country, now)
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// 5. 顺手裁剪:只保留最近 500 条,避免无限增长
|
// 5. 顺手裁剪:只保留最近 500 条,避免无限增长
|
||||||
|
|||||||
+38
-1
@@ -153,6 +153,38 @@ function renderMarkdown(f: {
|
|||||||
return `${fm.join('\n')}\n\n${f.body.trim()}\n`;
|
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 [
|
||||||
|
'<details>',
|
||||||
|
'<summary>提交环境信息(仅记录于 PR,未写入仓库文件)</summary>',
|
||||||
|
'',
|
||||||
|
`- **IP:** \`${m.ip || '未知'}\``,
|
||||||
|
`- **User-Agent:** \`${m.ua || '未知'}\``,
|
||||||
|
`- **国家/地区:** \`${m.country || '未知'}\``,
|
||||||
|
`- **提交时间:** \`${m.time}\``,
|
||||||
|
'',
|
||||||
|
'</details>',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 音乐投稿 ----------
|
// ---------- 音乐投稿 ----------
|
||||||
function renderSongJson(f: {
|
function renderSongJson(f: {
|
||||||
title: string; artist: string; link?: string;
|
title: string; artist: string; link?: string;
|
||||||
@@ -171,6 +203,7 @@ function renderSongJson(f: {
|
|||||||
|
|
||||||
async function handleSongSubmit(
|
async function handleSongSubmit(
|
||||||
env: Env, fd: FormData, json: (data: unknown, status?: number) => Response,
|
env: Env, fd: FormData, json: (data: unknown, status?: number) => Response,
|
||||||
|
meta: SubmitMeta,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const title = (fd.get('title') as string | null)?.trim() ?? '';
|
const title = (fd.get('title') as string | null)?.trim() ?? '';
|
||||||
const artist = (fd.get('artist') as string | null)?.trim() ?? '';
|
const artist = (fd.get('artist') as string | null)?.trim() ?? '';
|
||||||
@@ -259,6 +292,7 @@ async function handleSongSubmit(
|
|||||||
'',
|
'',
|
||||||
message ? `**留言:**\n\n${message}` : '',
|
message ? `**留言:**\n\n${message}` : '',
|
||||||
'',
|
'',
|
||||||
|
metaSection(meta),
|
||||||
'---',
|
'---',
|
||||||
'由音乐投稿表单自动创建,请审核后合并。',
|
'由音乐投稿表单自动创建,请审核后合并。',
|
||||||
].filter(Boolean).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
@@ -288,6 +322,8 @@ export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
|
|||||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const meta = requestMeta(request);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1) 解析 multipart
|
// 1) 解析 multipart
|
||||||
const ct = request.headers.get('content-type') || '';
|
const ct = request.headers.get('content-type') || '';
|
||||||
@@ -314,7 +350,7 @@ export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
|
|||||||
// 1.7) 音乐投稿分支(type=music)
|
// 1.7) 音乐投稿分支(type=music)
|
||||||
const submitType = (fd.get('type') as string | null)?.trim() ?? 'chronicle';
|
const submitType = (fd.get('type') as string | null)?.trim() ?? 'chronicle';
|
||||||
if (submitType === 'music') {
|
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() ?? '';
|
const title = (fd.get('title') as string | null)?.trim() ?? '';
|
||||||
@@ -449,6 +485,7 @@ export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
|
|||||||
'',
|
'',
|
||||||
message ? `**留言:**\n\n${message}` : '',
|
message ? `**留言:**\n\n${message}` : '',
|
||||||
'',
|
'',
|
||||||
|
metaSection(meta),
|
||||||
'---',
|
'---',
|
||||||
'由 Astro 投稿表单自动创建,请审核后合并。',
|
'由 Astro 投稿表单自动创建,请审核后合并。',
|
||||||
].filter(Boolean).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
|
|||||||
+19
@@ -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;
|
||||||
Reference in new issue
Block a user