first commit

This commit is contained in:
浪子 2026-03-19 11:07:49 +08:00
commit 7e927accac
20 changed files with 6322 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
.wrangler
.claude

65
README.md Normal file
View File

@ -0,0 +1,65 @@
# Cloudflare 在线聊天室D1 + KV + R2
`readme.txt` 需求实现的最小可用在线聊天室D1 存用户/房间/消息KV 做会话与房间访问令牌缓存R2 存图片。
## 功能概览
- 房间模式:开放/私密
- 当前版本只有一个房间:大厅(可选设置密码)
- 开放:所有人可浏览;必须注册登录才能发言
- 私密:需要密码进入;可选“允许游客匿名发言”
- 用户等级:匿名游客 / 注册会员 / 认证会员 / 管理员
- 注册会员:可发文字/表情;不可上传图片
- 认证会员可上传图片R2
- 管理员:可编辑用户资料与等级、封禁用户、删除消息
- 页面:注册、登录、找回密码、房间列表、聊天窗口(左右气泡)
- 实时SSEEventSource推送简化实现适合 MVP
## 本地开发
1) 安装依赖
```bash
npm i
```
2) 创建并迁移 D1
```bash
npx wrangler d1 create chat_db
# 把输出的 database_id 填到 wrangler.toml
npx wrangler d1 execute chat_db --file=./schema.sql
```
3) 创建 KV / R2并填入 `wrangler.toml`
```bash
npx wrangler kv namespace create CACHE
npx wrangler r2 bucket create chat-bucket
```
4) 启动
```bash
npm run dev
```
## 部署
```bash
npm run deploy
```
## 管理员
生产/开发环境:第一个注册成功的用户会自动提升为管理员(后续用户为注册会员)。
开发环境也可用接口创建管理员(`wrangler.toml` 的 `DEV_MODE = "true"`
```bash
curl -X POST http://localhost:8787/api/admin/debug/create-admin ^
-H "content-type: application/json" ^
-d "{\"username\":\"admin\",\"password\":\"admin123\"}"
```
管理员可在页面右侧“管理 → 大厅密码”设置/取消大厅密码:设置后游客登录会要求输入该密码;取消后游客登录直接进入。

1987
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "cf-chat",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241206.0",
"typescript": "^5.6.3",
"wrangler": "^3.100.0"
},
"dependencies": {
"resend": "^6.9.1"
}
}

1092
public/app.css Normal file

File diff suppressed because it is too large Load Diff

1403
public/app.js Normal file

File diff suppressed because it is too large Load Diff

282
public/index.html Normal file
View File

@ -0,0 +1,282 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>在线聊天室</title>
<script>
(() => {
try {
const saved = localStorage.getItem("theme");
const theme =
saved === "dark" || saved === "light"
? saved
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.dataset.theme = theme;
} catch {}
})();
</script>
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<div class="scene-container">
<main class="chat-room" id="chatRoom">
<div class="chat-interface">
<header class="topbar">
<div class="brand">聊天室</div>
<div class="topbarRight">
<div class="me" id="meBox"></div>
<button class="btn iconBtn themeBtn" id="themeBtn" type="button" aria-label="切换黑夜模式" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z" />
</svg>
</button>
<button class="btn iconBtn menuBtn" id="menuBtn" type="button" aria-label="账户与管理">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21a8 8 0 0 0-16 0" />
<circle cx="12" cy="8" r="4" />
</svg>
</button>
</div>
</header>
<div class="menuDropdown" id="menuDropdown" hidden>
<div class="menuDropdownCard card" id="menuDropdownCard">
<div class="menuDropdownHeader">
<div class="menuDropdownTitle">菜单</div>
<button class="btn iconBtn" id="menuCloseBtn" type="button" aria-label="关闭菜单">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</div>
<div id="menuHost">
<details class="card fold" id="accountFold">
<summary class="foldSummary">账户</summary>
<div class="foldBody">
<div id="authBox"></div>
</div>
</details>
<details class="card fold" id="adminCard" style="display:none">
<summary class="foldSummary">管理</summary>
<div class="foldBody">
<div class="row">
<button id="loadUsersBtn" class="btn">刷新用户</button>
<button id="lobbyPwdBtn" class="btn">大厅密码</button>
</div>
<div id="adminToolsBox" class="stack" style="margin-top:10px" hidden></div>
<div class="hint" style="margin-top:10px">用户列表</div>
<div id="adminUsersBox" class="admin"></div>
</div>
</details>
</div>
</div>
</div>
<main class="layout">
<aside class="sidebar">
<div class="card" id="onlineCard">
<div class="row" style="justify-content:space-between; align-items:center">
<div style="font-weight:900; color:var(--text-main); font-size:13px">在线用户</div>
<div class="hint" id="onlineCount"></div>
</div>
<div class="onlineList" id="onlineList"></div>
</div>
</aside>
<section class="chat">
<div class="chatHeader">
<div>
<div class="chatTitle" id="roomTitle">未进入聊天室</div>
<div class="chatSubtitle" id="roomSubtitle"></div>
</div>
<div class="row">
<button id="leaveRoomBtn" class="btn" disabled>离开</button>
</div>
</div>
<div class="messages" id="messages"></div>
<div class="composer">
<div class="composerRow">
<button class="btn iconBtn toolBtn" id="emojiBtn" type="button" aria-label="表情" disabled>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</svg>
</button>
<button class="btn iconBtn toolBtn" id="imageBtn" type="button" aria-label="图片" disabled hidden>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14Z" />
<path d="M8.5 10.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="m21 15-5-5L5 21" />
</svg>
</button>
<div class="msgInputWrap" id="msgInputWrap">
<img id="inlineThumb" class="inlineThumb" alt="待发送图片" hidden />
<input id="msgInput" class="input" placeholder="输入消息…" disabled />
</div>
<button id="sendBtn" class="btn btnPrimary" disabled>发送</button>
</div>
<input id="fileInput" type="file" accept="image/*" hidden />
<div class="composerAttach" id="composerAttach" hidden></div>
<div class="emojiPanel" id="emojiPanel" hidden></div>
</div>
</section>
</main>
</div>
</main>
<div class="doors-container" id="doorsContainer" aria-hidden="true">
<div class="door left">
<div class="door-handle"></div>
</div>
<div class="door right">
<div class="door-handle"></div>
</div>
</div>
<div class="login-wrapper" id="loginWrapper">
<div class="login-card">
<h1>进入聊天室</h1>
<p>门已锁好,请验证身份进入</p>
<div class="gateTabs">
<button class="btn btnPrimary" id="gateTabLogin" type="button">登录</button>
<button class="btn" id="gateTabRegister" type="button">注册</button>
<button class="btn" id="gateTabGuest" type="button">游客登录</button>
</div>
</div>
</div>
<!-- 登录/注册/游客 模态框 -->
<div class="confirm" id="gateModal" hidden>
<div class="confirmCard gateModalCard">
<div class="gateModalHeader">
<div class="confirmTitle" id="gateModalTitle"></div>
<button class="btn iconBtn" id="gateModalClose" type="button" aria-label="关闭">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" /><path d="M6 6l12 12" />
</svg>
</button>
</div>
<div class="error-msg" id="gateError"></div>
<form id="gateLoginForm" class="gatePanel" hidden>
<div class="input-group">
<label for="gateLoginUser">用户名</label>
<input id="gateLoginUser" autocomplete="username" />
</div>
<div class="input-group">
<label for="gateLoginPass">密码</label>
<input id="gateLoginPass" type="password" autocomplete="current-password" />
</div>
<button class="login-btn" type="submit">登录并进入</button>
<button class="link" type="button" id="forgotPasswordBtn">找回密码</button>
</form>
<form id="gateRegisterForm" class="gatePanel" hidden>
<div class="input-group">
<label for="gateRegUser">用户名</label>
<input id="gateRegUser" autocomplete="username" />
</div>
<div class="input-group">
<label for="gateRegPass">密码</label>
<input id="gateRegPass" type="password" autocomplete="new-password" />
</div>
<div class="input-group">
<label for="gateRegEmail">邮箱(选填)</label>
<input id="gateRegEmail" autocomplete="email" />
</div>
<div class="input-group">
<label for="gateRegQq">QQ选填</label>
<input id="gateRegQq" autocomplete="off" />
</div>
<div class="input-group">
<label for="gateRegPhone">联系电话(选填)</label>
<input id="gateRegPhone" autocomplete="tel" />
</div>
<button class="login-btn" type="submit">注册并进入</button>
</form>
<form id="gateGuestForm" class="gatePanel" hidden>
<input id="gateGuestUser" class="srOnly" name="username" autocomplete="username" tabindex="-1" />
<div class="hint" id="gateGuestNameHint"></div>
<div class="input-group">
<label for="gateGuestPass">聊天室密码(若已设置)</label>
<input id="gateGuestPass" type="password" autocomplete="current-password" />
<div class="hint" id="gateGuestHint"></div>
</div>
<button class="login-btn" type="submit">游客进入</button>
</form>
</div>
</div>
<!-- 找回密码模态框 -->
<div class="confirm" id="forgotModal" hidden>
<div class="confirmCard" style="width:min(480px,92vw)">
<div class="confirmTitle" id="forgotModalTitle">找回密码</div>
<!-- 步骤1 -->
<div id="forgotStep1" class="stack" style="margin-top:12px">
<div class="hint">请输入你的用户名,我们将向注册邮箱发送 6 位数字验证码。</div>
<div class="input-group">
<label for="forgotUsername">用户名</label>
<input id="forgotUsername" autocomplete="username" />
</div>
<div class="hint" id="forgotStep1Msg"></div>
<div class="row" style="justify-content:flex-end;margin-top:4px">
<button class="btn" id="forgotCancelBtn" type="button">取消</button>
<button class="btn btnPrimary" id="forgotSendBtn" type="button">发送验证码</button>
</div>
</div>
<!-- 步骤2 -->
<div id="forgotStep2" class="stack" style="margin-top:12px;display:none">
<div class="hint">请输入邮件中收到的 6 位数字验证码和新密码。验证码有效期 30 分钟。</div>
<div class="input-group">
<label for="forgotToken">验证码</label>
<input id="forgotToken" maxlength="6" pattern="[0-9]*" inputmode="numeric" autocomplete="one-time-code" />
</div>
<div class="input-group">
<label for="forgotNewPass">新密码</label>
<input id="forgotNewPass" type="password" autocomplete="new-password" />
</div>
<div class="hint" id="forgotStep2Msg"></div>
<div class="row" style="justify-content:flex-end;margin-top:4px">
<button class="btn" id="forgotBackBtn" type="button">返回</button>
<button class="btn btnPrimary" id="forgotResetBtn" type="button">重置密码</button>
</div>
</div>
</div>
</div>
<div class="imgViewer" id="imgViewer" hidden>
<div class="imgViewerBackdrop" id="imgViewerBackdrop"></div>
<img class="imgViewerImg" id="imgViewerImg" alt="图片预览" />
<button class="imgViewerClose" id="imgViewerClose" type="button">关闭</button>
</div>
<div class="toast" id="toast" hidden></div>
<div class="confirm" id="confirm" hidden>
<div class="confirmCard">
<div class="confirmTitle" id="confirmTitle"></div>
<div class="confirmMsg" id="confirmMsg"></div>
<div class="row" style="justify-content:flex-end">
<button class="btn" id="confirmCancel" type="button">取消</button>
<button class="btn btnPrimary" id="confirmOk" type="button">确定</button>
</div>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

153
public/reset.html Normal file
View File

@ -0,0 +1,153 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>找回密码</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<div class="center">
<div class="card wide">
<div class="cardTitle">找回密码</div>
<!-- 步骤1: 请求发送重置邮件 -->
<div id="requestStep" class="stack">
<div class="hint">请输入你的用户名,我们将向你注册时填写的邮箱发送 6 位数字验证码。</div>
<input id="requestUsername" class="input" placeholder="用户名" autocomplete="username" />
<button id="requestBtn" class="btn btnPrimary">发送验证码</button>
<div id="requestMsg" class="hint"></div>
<a class="link" href="/">返回聊天室</a>
</div>
<!-- 步骤2: 输入 token 重置密码 -->
<div id="resetStep" class="stack" style="display: none;">
<div class="hint">请输入邮件中收到的 6 位数字验证码和新密码。验证码有效期 30 分钟。</div>
<input id="token" class="input" placeholder="6 位数字验证码" maxlength="6" pattern="[0-9]*" inputmode="numeric" />
<input id="newPassword" class="input" placeholder="新密码至少6位" type="password" autocomplete="new-password" />
<button id="resetBtn" class="btn btnPrimary">重置密码</button>
<div id="resetMsg" class="hint"></div>
<button id="backToRequestBtn" class="btn" style="margin-top: 10px;">返回重新发送</button>
<a class="link" href="/">返回聊天室</a>
</div>
</div>
</div>
<script type="module">
const $ = (id) => document.getElementById(id);
const params = new URLSearchParams(location.search);
// 如果 URL 中有 token直接显示重置步骤
const urlToken = params.get("token");
if (urlToken) {
$("token").value = urlToken;
$("requestStep").style.display = "none";
$("resetStep").style.display = "block";
}
// 请求发送重置邮件
$("requestBtn").addEventListener("click", async (e) => {
e.preventDefault();
const username = $("requestUsername").value.trim();
if (!username) {
$("requestMsg").textContent = "请输入用户名";
$("requestMsg").style.color = "red";
return;
}
$("requestBtn").disabled = true;
$("requestMsg").textContent = "发送中...";
$("requestMsg").style.color = "";
try {
const resp = await fetch("/api/auth/request-password-reset", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username }),
});
const data = await resp.json().catch(() => ({}));
if (resp.ok) {
// 开发模式可能返回 devResetLink
if (data.devResetLink) {
$("requestMsg").innerHTML = `开发模式:<a href="${data.devResetLink}" class="link">点击这里重置密码</a>`;
$("requestMsg").style.color = "green";
} else {
$("requestMsg").textContent = data.message || "✅ 重置链接已发送到邮箱,请查收";
$("requestMsg").style.color = "green";
// 切换到重置步骤
setTimeout(() => {
$("requestStep").style.display = "none";
$("resetStep").style.display = "block";
}, 2000);
}
} else {
$("requestMsg").textContent = data.error || "请求失败,请稍后重试";
$("requestMsg").style.color = "red";
}
} catch (err) {
$("requestMsg").textContent = "网络错误,请稍后重试";
$("requestMsg").style.color = "red";
} finally {
$("requestBtn").disabled = false;
}
});
// 重置密码
$("resetBtn").addEventListener("click", async (e) => {
e.preventDefault();
const token = $("token").value.trim();
const newPassword = $("newPassword").value;
if (!token) {
$("resetMsg").textContent = "请输入验证码";
$("resetMsg").style.color = "red";
return;
}
if (!/^\d{6}$/.test(token)) {
$("resetMsg").textContent = "验证码必须是 6 位数字";
$("resetMsg").style.color = "red";
return;
}
if (newPassword.length < 6) {
$("resetMsg").textContent = "密码至少需要 6 位";
$("resetMsg").style.color = "red";
return;
}
$("resetBtn").disabled = true;
$("resetMsg").textContent = "重置中...";
$("resetMsg").style.color = "";
try {
const resp = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, newPassword }),
});
const data = await resp.json().catch(() => ({}));
if (resp.ok) {
$("resetMsg").textContent = "✅ 密码已重置成功!请返回登录";
$("resetMsg").style.color = "green";
} else {
$("resetMsg").textContent = data.error || "重置失败,验证码可能已过期";
$("resetMsg").style.color = "red";
}
} catch (err) {
$("resetMsg").textContent = "网络错误,请稍后重试";
$("resetMsg").style.color = "red";
} finally {
$("resetBtn").disabled = false;
}
});
// 返回重新发送
$("backToRequestBtn").addEventListener("click", () => {
$("resetStep").style.display = "none";
$("requestStep").style.display = "block";
$("requestMsg").textContent = "";
$("resetMsg").textContent = "";
});
</script>
</body>
</html>

52
schema.sql Normal file
View File

@ -0,0 +1,52 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email TEXT,
qq TEXT,
phone TEXT,
level INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_private INTEGER NOT NULL,
password_hash TEXT,
allow_anonymous INTEGER NOT NULL,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
user_id TEXT,
sender_name TEXT NOT NULL,
type TEXT NOT NULL,
content TEXT,
r2_key TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_room_id_id ON messages(room_id, id);
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
reason TEXT,
until INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS password_resets (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
email TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);

29
src/env.ts Normal file
View File

@ -0,0 +1,29 @@
export type Env = {
DB: D1Database;
CACHE?: KVNamespace;
MEMOS_CACHE?: KVNamespace;
BUCKET: R2Bucket;
ASSETS: Fetcher;
SESSION_TTL_SECONDS?: string;
ROOM_ACCESS_TTL_SECONDS?: string;
DEV_MODE?: string;
RESEND_API_KEY?: string;
RESEND_FROM_EMAIL?: string;
};
export function getCache(env: Env): KVNamespace {
const kv = env.CACHE ?? env.MEMOS_CACHE;
if (!kv) throw new Error('KV 未绑定:请在 wrangler.toml 里绑定 "CACHE"');
return kv;
}
export function getNumberVar(env: Env, key: keyof Env, fallback: number): number {
const value = env[key];
if (typeof value !== "string") return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function isDevMode(env: Env): boolean {
return String(env.DEV_MODE ?? "").toLowerCase() === "true";
}

748
src/index.ts Normal file
View File

@ -0,0 +1,748 @@
import type { Env } from "./env";
import { isDevMode } from "./env";
import { clearCookie, setCookie } from "./lib/cookies";
import { hashPassword, randomNumericCode, randomToken, verifyPassword } from "./lib/crypto";
import { sendPasswordResetEmail } from "./lib/email";
import { forbidden, json, notFound, readJson, unauthorized } from "./lib/http";
import { createRoomAccessToken, verifyRoomAccessToken } from "./lib/roomAccess";
import { createSession, destroySession, getSession } from "./lib/sessions";
import { sleep } from "./lib/sleep";
type User = {
id: string;
username: string;
email: string | null;
qq: string | null;
phone: string | null;
level: number;
created_at: number;
};
type Room = {
id: string;
name: string;
is_private: number;
allow_anonymous: number;
created_by: string;
created_at: number;
};
type Message = {
id: number;
room_id: string;
user_id: string | null;
sender_name: string;
sender_level: number;
type: string;
content: string | null;
r2_key: string | null;
created_at: number;
};
function pickPublicUser(user: User) {
return {
id: user.id,
username: user.username,
email: user.email,
qq: user.qq,
phone: user.phone,
level: user.level,
created_at: user.created_at,
};
}
async function getUserById(env: Env, id: string): Promise<User | null> {
const row = await env.DB.prepare(
"SELECT id, username, email, qq, phone, level, created_at FROM users WHERE id = ?",
)
.bind(id)
.first<User>();
return row ?? null;
}
async function getUserByUsernameForLogin(
env: Env,
username: string,
): Promise<(User & { password_hash: string }) | null> {
const row = await env.DB.prepare(
"SELECT id, username, password_hash, email, qq, phone, level, created_at FROM users WHERE username = ?",
)
.bind(username)
.first<User & { password_hash: string }>();
return row ?? null;
}
async function isUserBanned(env: Env, userId: string): Promise<{ banned: boolean; reason?: string | null }> {
const now = Date.now();
const row = await env.DB.prepare(
"SELECT reason, until FROM bans WHERE user_id = ? ORDER BY id DESC LIMIT 1",
)
.bind(userId)
.first<{ reason: string | null; until: number | null }>();
if (!row) return { banned: false };
if (row.until && row.until < now) return { banned: false };
return { banned: true, reason: row.reason };
}
async function getRoom(env: Env, roomId: string): Promise<(Room & { password_hash: string | null }) | null> {
const row = await env.DB.prepare(
"SELECT id, name, is_private, password_hash, allow_anonymous, created_by, created_at FROM rooms WHERE id = ?",
)
.bind(roomId)
.first<Room & { password_hash: string | null }>();
return row ?? null;
}
const LOBBY_ROOM_ID = "lobby";
async function ensureLobbyRoom(env: Env): Promise<Room & { password_hash: string | null }> {
const existing = await getRoom(env, LOBBY_ROOM_ID);
if (existing) {
if (existing.is_private && !existing.password_hash) {
await env.DB.prepare("UPDATE rooms SET is_private = 0, password_hash = NULL WHERE id = ?")
.bind(existing.id)
.run();
const fixed = await getRoom(env, LOBBY_ROOM_ID);
if (!fixed) throw new Error("Failed to load lobby room");
return fixed;
}
return existing;
}
const now = Date.now();
await env.DB.prepare(
"INSERT INTO rooms (id, name, is_private, password_hash, allow_anonymous, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(LOBBY_ROOM_ID, "大厅", 0, null, 1, "system", now)
.run();
const created = await getRoom(env, LOBBY_ROOM_ID);
if (!created) throw new Error("Failed to create lobby room");
return created;
}
async function requireAdmin(env: Env, request: Request): Promise<User | Response> {
const session = await getSession(env, request);
if (!session) return unauthorized();
const user = await getUserById(env, session.userId);
if (!user) return unauthorized();
if (user.level < 3) return forbidden("需要管理员权限");
return user;
}
async function requireLogin(env: Env, request: Request): Promise<User | Response> {
const session = await getSession(env, request);
if (!session) return unauthorized();
const user = await getUserById(env, session.userId);
if (!user) return unauthorized();
const ban = await isUserBanned(env, user.id);
if (ban.banned) return forbidden(`你已被封禁${ban.reason ? `${ban.reason}` : ""}`);
return user;
}
async function requireRoomAccessIfPrivate(
env: Env,
request: Request,
room: Room & { password_hash: string | null },
): Promise<{ access: { userId?: string; nickname?: string } | null } | Response> {
if (!room.is_private) return { access: null };
const url = new URL(request.url);
const accessToken = url.searchParams.get("accessToken") ?? request.headers.get("x-room-access") ?? null;
if (!accessToken) return forbidden("私密房间需要密码进入");
const access = await verifyRoomAccessToken(env, room.id, accessToken);
if (!access) return forbidden("房间访问令牌无效/已过期,请重新输入密码进入");
return { access };
}
function sanitizeUsername(username: string): string | null {
const u = username.trim();
if (u.length < 2 || u.length > 20) return null;
if (!/^[\p{L}\p{N}_-]+$/u.test(u)) return null;
return u;
}
function sanitizeNickname(nickname: string): string | null {
const v = nickname.trim();
if (v.length < 1 || v.length > 20) return null;
return v;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (!url.pathname.startsWith("/api/")) {
return env.ASSETS.fetch(request);
}
try {
return await handleApi(request, env, ctx);
} catch (err) {
console.error(err);
const message = err instanceof Error ? err.message : String(err);
return json({ error: isDevMode(env) ? message : "服务器错误" }, { status: 500 });
}
},
};
async function handleApi(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const { pathname } = url;
if (pathname === "/api/health") return json({ ok: true });
if (pathname === "/api/lobby" && request.method === "GET") {
const lobby = await ensureLobbyRoom(env);
return json({
room: {
id: lobby.id,
name: lobby.name,
is_private: lobby.is_private,
allow_anonymous: lobby.allow_anonymous,
created_by: lobby.created_by,
created_at: lobby.created_at,
},
guestPasswordRequired: Boolean(lobby.is_private),
});
}
if (pathname === "/api/me" && request.method === "GET") {
const session = await getSession(env, request);
if (!session) return json({ user: null });
const user = await getUserById(env, session.userId);
if (!user) return json({ user: null });
return json({ user: pickPublicUser(user) });
}
if (pathname === "/api/auth/register" && request.method === "POST") {
const body = await readJson<{
username?: string;
password?: string;
email?: string;
qq?: string;
phone?: string;
}>(request);
if (!body) return json({ error: "无效 JSON" }, { status: 400 });
const username = body.username ? sanitizeUsername(body.username) : null;
const password = (body.password ?? "").trim();
if (!username) return json({ error: "用户名不合法2-20 位,仅字母数字 _-" }, { status: 400 });
if (username.startsWith("游客")) return json({ error: "用户名不能以“游客”开头(该前缀保留给匿名游客)" }, { status: 400 });
if (password.length < 6) return json({ error: "密码至少 6 位" }, { status: 400 });
const passwordHash = await hashPassword(password);
const id = crypto.randomUUID();
const now = Date.now();
try {
// 首个注册用户自动成为管理员level=3其余为注册会员level=1
await env.DB.prepare(
"INSERT INTO users (id, username, password_hash, email, qq, phone, level, created_at) VALUES (?, ?, ?, ?, ?, ?, (CASE WHEN (SELECT COUNT(1) FROM users) = 0 THEN 3 ELSE 1 END), ?)",
)
.bind(
id,
username,
passwordHash,
body.email?.trim() || null,
body.qq?.trim() || null,
body.phone?.trim() || null,
now,
)
.run();
} catch {
return json({ error: "用户名已存在" }, { status: 409 });
}
let sessionCookie: string;
try {
({ setCookie: sessionCookie } = await createSession(env, id));
} catch (err) {
console.error(err);
return json({ error: "会话服务不可用KV 未绑定或异常),请检查 KV 绑定" }, { status: 500 });
}
const headers = new Headers();
setCookie(headers, sessionCookie);
return json({ ok: true }, { headers });
}
if (pathname === "/api/auth/login" && request.method === "POST") {
const body = await readJson<{ username?: string; password?: string }>(request);
if (!body) return json({ error: "无效 JSON" });
const username = body.username ? sanitizeUsername(body.username) : null;
const password = (body.password ?? "").trim();
if (!username || !password) return json({ error: "用户名或密码错误" });
const user = await getUserByUsernameForLogin(env, username);
if (!user) return json({ error: "用户名或密码错误" });
const ok = await verifyPassword(password, user.password_hash);
if (!ok) return json({ error: "用户名或密码错误" });
const ban = await isUserBanned(env, user.id);
if (ban.banned) return forbidden(`你已被封禁${ban.reason ? `${ban.reason}` : ""}`);
let sessionCookie: string;
try {
({ setCookie: sessionCookie } = await createSession(env, user.id));
} catch (err) {
console.error(err);
return json({ error: "会话服务不可用KV 未绑定或异常),请检查 KV 绑定" }, { status: 500 });
}
const headers = new Headers();
setCookie(headers, sessionCookie);
return json({ ok: true }, { headers });
}
if (pathname === "/api/auth/debug/user" && request.method === "GET") {
if (!isDevMode(env)) return forbidden("仅 DEV_MODE 可用");
const usernameRaw = url.searchParams.get("username") ?? "";
const username = sanitizeUsername(usernameRaw) ?? "";
if (!username) return json({ exists: false, reason: "invalid_username" });
const row = await env.DB.prepare("SELECT id, username, email, level, created_at FROM users WHERE username = ?")
.bind(username)
.first<{ id: string; username: string; email: string | null; level: number; created_at: number }>();
if (!row) return json({ exists: false });
return json({
exists: true,
user: {
id: row.id,
username: row.username,
hasEmail: Boolean(row.email),
level: row.level,
created_at: row.created_at,
},
});
}
if (pathname === "/api/auth/logout" && request.method === "POST") {
const cookie = await destroySession(env, request);
const headers = new Headers();
if (cookie) setCookie(headers, cookie);
return json({ ok: true }, { headers });
}
if (pathname === "/api/auth/request-password-reset" && request.method === "POST") {
const body = await readJson<{ username?: string }>(request);
if (!body?.username) return json({ error: "请输入用户名" }, { status: 400 });
const username = sanitizeUsername(body.username);
if (!username) return json({ error: "用户名格式不正确" }, { status: 400 });
const user = await env.DB.prepare("SELECT id, username, email FROM users WHERE username = ?")
.bind(username)
.first<{ id: string; username: string; email: string | null }>();
if (!user) {
return json({ error: "用户不存在" }, { status: 404 });
}
if (!user.email) {
return json({ error: "该账号未绑定邮箱,无法找回密码" }, { status: 400 });
}
const token = randomNumericCode(6); // 生成 6 位数字验证码
const now = Date.now();
const expiresAt = now + 1000 * 60 * 30;
await env.DB.prepare(
"INSERT INTO password_resets (token, user_id, email, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(token, user.id, user.email, expiresAt, now)
.run();
if (isDevMode(env)) {
return json({ ok: true, devResetLink: `/reset.html?token=${encodeURIComponent(token)}` });
}
// 生产模式:发送邮件
const emailResult = await sendPasswordResetEmail(env, user.email, token, user.username);
if (!emailResult.success) {
console.error("发送密码重置邮件失败:", emailResult.error);
return json({ error: "邮件发送失败,请稍后重试" }, { status: 500 });
}
return json({ ok: true, message: "重置链接已发送到你的邮箱,请查收" });
}
if (pathname === "/api/auth/reset-password" && request.method === "POST") {
const body = await readJson<{ token?: string; newPassword?: string }>(request);
const token = (body?.token ?? "").trim();
const newPassword = (body?.newPassword ?? "").trim();
if (!token || newPassword.length < 6) return json({ error: "参数错误" }, { status: 400 });
const row = await env.DB.prepare("SELECT user_id, expires_at FROM password_resets WHERE token = ?")
.bind(token)
.first<{ user_id: string; expires_at: number }>();
if (!row || row.expires_at < Date.now()) return json({ error: "链接无效或已过期" }, { status: 400 });
const newHash = await hashPassword(newPassword);
await env.DB.batch([
env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?").bind(newHash, row.user_id),
env.DB.prepare("DELETE FROM password_resets WHERE token = ?").bind(token),
]);
return json({ ok: true });
}
if (pathname === "/api/rooms" && request.method === "GET") {
const lobby = await ensureLobbyRoom(env);
return json({
rooms: [
{
id: lobby.id,
name: lobby.name,
is_private: lobby.is_private,
allow_anonymous: lobby.allow_anonymous,
created_by: lobby.created_by,
created_at: lobby.created_at,
},
],
});
}
if (pathname === "/api/rooms" && request.method === "POST") {
return forbidden("当前版本仅支持一个聊天室(大厅)");
}
const joinMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/join$/);
if (joinMatch && request.method === "POST") {
const roomId = joinMatch[1]!;
const room = await getRoom(env, roomId);
if (!room) return json({ error: "房间不存在" }, { status: 404 });
const body = await readJson<{ password?: string; nickname?: string }>(request);
const session = await getSession(env, request);
if (!session) {
const password = (body?.password ?? "").trim();
if (room.is_private) {
if (!room.password_hash) return json({ error: "房间配置错误:缺少密码" }, { status: 500 });
const ok = await verifyPassword(password, room.password_hash);
if (!ok) return forbidden("密码错误");
}
if (!room.is_private) return unauthorized("开放房间发言需要登录(可浏览无需加入)");
if (!room.allow_anonymous) return unauthorized("该私密房间不允许游客匿名发言,请登录");
const nickname = body?.nickname ? sanitizeNickname(body.nickname) : null;
if (!nickname) return json({ error: "请输入昵称1-20" }, { status: 400 });
const { token, ttlSeconds } = await createRoomAccessToken(env, roomId, { nickname });
return json({ ok: true, accessToken: token, ttlSeconds, me: { nickname } });
}
if (room.is_private && room.id !== LOBBY_ROOM_ID) {
const password = (body?.password ?? "").trim();
if (!room.password_hash) return json({ error: "房间配置错误:缺少密码" }, { status: 500 });
const ok = await verifyPassword(password, room.password_hash);
if (!ok) return forbidden("密码错误");
}
const user = await getUserById(env, session.userId);
if (!user) return unauthorized();
const ban = await isUserBanned(env, user.id);
if (ban.banned) return forbidden(`你已被封禁${ban.reason ? `${ban.reason}` : ""}`);
const { token, ttlSeconds } = await createRoomAccessToken(env, roomId, { userId: user.id });
return json({ ok: true, accessToken: token, ttlSeconds, me: pickPublicUser(user) });
}
const messagesMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/);
if (messagesMatch && request.method === "GET") {
const roomId = messagesMatch[1]!;
const room = await getRoom(env, roomId);
if (!room) return json({ error: "房间不存在" }, { status: 404 });
const accessResp = await requireRoomAccessIfPrivate(env, request, room);
if (accessResp instanceof Response) return accessResp;
const after = Number(url.searchParams.get("after") ?? "0");
const afterId = Number.isFinite(after) && after >= 0 ? after : 0;
const rows = await env.DB.prepare(
"SELECT m.id, m.room_id, m.user_id, m.sender_name, COALESCE(u.level, 0) as sender_level, m.type, m.content, m.r2_key, m.created_at FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.room_id = ? AND m.id > ? ORDER BY m.id ASC LIMIT 200",
)
.bind(roomId, afterId)
.all<Message>();
return json({ messages: rows.results });
}
if (messagesMatch && request.method === "POST") {
const roomId = messagesMatch[1]!;
const room = await getRoom(env, roomId);
if (!room) return json({ error: "房间不存在" }, { status: 404 });
const body = await readJson<{ type?: string; content?: string; accessToken?: string }>(request);
if (!body) return json({ error: "无效 JSON" }, { status: 400 });
const type = (body.type ?? "text").trim();
const content = (body.content ?? "").trim();
if (!content) return json({ error: "内容不能为空" }, { status: 400 });
if (content.length > 2000) return json({ error: "内容过长" }, { status: 400 });
let senderUserId: string | null = null;
let senderName: string | null = null;
let level = 0;
const session = await getSession(env, request);
if (session) {
const user = await getUserById(env, session.userId);
if (!user) return unauthorized();
const ban = await isUserBanned(env, user.id);
if (ban.banned) return forbidden(`你已被封禁${ban.reason ? `${ban.reason}` : ""}`);
senderUserId = user.id;
senderName = user.username;
level = user.level;
}
if (!room.is_private && !senderUserId) {
return unauthorized("开放房间必须登录才能发言");
}
if (room.is_private) {
const accessToken = (body.accessToken ?? "").trim();
if (!accessToken) return forbidden("私密房间需要先输入密码进入");
const access = await verifyRoomAccessToken(env, roomId, accessToken);
if (!access) return forbidden("房间访问令牌无效/已过期,请重新进入");
if (!senderUserId) {
if (!room.allow_anonymous) return unauthorized("该私密房间不允许游客匿名发言,请登录");
senderName = access.nickname ?? "游客";
}
}
if (!senderName) return unauthorized();
if (type !== "text" && type !== "emoji" && type !== "link" && type !== "note") {
return json({ error: "不支持的消息类型" }, { status: 400 });
}
if (!senderUserId && type !== "text" && type !== "emoji") {
return forbidden("游客仅支持文字/表情");
}
if (senderUserId && level < 1) return forbidden("无权限发言");
const now = Date.now();
const result = await env.DB.prepare(
"INSERT INTO messages (room_id, user_id, sender_name, type, content, r2_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(roomId, senderUserId, senderName, type, content, null, now)
.run();
return json({
ok: true,
message: {
id: result.meta.last_row_id as number,
room_id: roomId,
user_id: senderUserId,
sender_name: senderName,
sender_level: level,
type,
content,
r2_key: null,
created_at: now,
} satisfies Message,
});
}
const uploadMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/upload$/);
if (uploadMatch && request.method === "POST") {
const roomId = uploadMatch[1]!;
const room = await getRoom(env, roomId);
if (!room) return json({ error: "房间不存在" }, { status: 404 });
const userOrResp = await requireLogin(env, request);
if (userOrResp instanceof Response) return userOrResp;
if (userOrResp.level < 2) return forbidden("只有认证会员及以上可以上传图片");
const form = await request.formData();
const accessToken = String(form.get("accessToken") ?? "");
if (room.is_private) {
const access = await verifyRoomAccessToken(env, roomId, accessToken);
if (!access) return forbidden("私密房间需要先输入密码进入");
}
const file = form.get("file");
if (!(file instanceof File)) return json({ error: "缺少文件" }, { status: 400 });
if (!file.type.startsWith("image/")) return json({ error: "仅支持图片" }, { status: 400 });
if (file.size > 5 * 1024 * 1024) return json({ error: "图片最大 5MB" }, { status: 400 });
const safeName = (file.name || "image").replace(/[^\p{L}\p{N}._-]/gu, "_").slice(0, 80);
const key = `${roomId}/${userOrResp.id}/${Date.now()}_${safeName}`;
const buf = await file.arrayBuffer();
await env.BUCKET.put(key, buf, { httpMetadata: { contentType: file.type } });
const now = Date.now();
const result = await env.DB.prepare(
"INSERT INTO messages (room_id, user_id, sender_name, type, content, r2_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(roomId, userOrResp.id, userOrResp.username, "image", null, key, now)
.run();
return json({
ok: true,
message: {
id: result.meta.last_row_id as number,
room_id: roomId,
user_id: userOrResp.id,
sender_name: userOrResp.username,
sender_level: userOrResp.level,
type: "image",
content: null,
r2_key: key,
created_at: now,
} satisfies Message,
});
}
const streamMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/stream$/);
if (streamMatch && request.method === "GET") {
const roomId = streamMatch[1]!;
const room = await getRoom(env, roomId);
if (!room) return json({ error: "房间不存在" }, { status: 404 });
const accessResp = await requireRoomAccessIfPrivate(env, request, room);
if (accessResp instanceof Response) return accessResp;
const after = Number(url.searchParams.get("after") ?? "0");
let afterId = Number.isFinite(after) && after >= 0 ? after : 0;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(`event: hello\ndata: {}\n\n`));
while (!request.signal.aborted) {
const rows = await env.DB.prepare(
"SELECT m.id, m.room_id, m.user_id, m.sender_name, COALESCE(u.level, 0) as sender_level, m.type, m.content, m.r2_key, m.created_at FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.room_id = ? AND m.id > ? ORDER BY m.id ASC LIMIT 100",
)
.bind(roomId, afterId)
.all<Message>();
for (const msg of rows.results) {
afterId = Math.max(afterId, msg.id);
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(msg)}\n\n`));
}
await sleep(300, request.signal);
}
controller.close();
},
cancel() {},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
},
});
}
const imageMatch = pathname.match(/^\/api\/images\/(.+)$/);
if (imageMatch && request.method === "GET") {
const key = decodeURIComponent(imageMatch[1]!);
const roomId = key.split("/")[0] ?? "";
if (!roomId) return notFound();
const room = await getRoom(env, roomId);
if (!room) return notFound();
if (room.is_private) {
const accessToken = url.searchParams.get("accessToken") ?? "";
const access = await verifyRoomAccessToken(env, roomId, accessToken);
if (!access) return forbidden("私密房间图片需要先输入密码进入");
}
const obj = await env.BUCKET.get(key);
if (!obj) return notFound();
const headers = new Headers();
obj.writeHttpMetadata(headers);
headers.set("cache-control", "public, max-age=31536000, immutable");
return new Response(obj.body, { headers });
}
// --- Admin APIs ---
if (pathname === "/api/admin/users" && request.method === "GET") {
const adminOrResp = await requireAdmin(env, request);
if (adminOrResp instanceof Response) return adminOrResp;
const users = await env.DB.prepare(
"SELECT id, username, email, qq, phone, level, created_at FROM users ORDER BY created_at DESC LIMIT 200",
).all<User>();
return json({ users: users.results });
}
if (pathname === "/api/admin/lobby/password" && request.method === "POST") {
const adminOrResp = await requireAdmin(env, request);
if (adminOrResp instanceof Response) return adminOrResp;
const lobby = await ensureLobbyRoom(env);
const body = await readJson<{ password?: string | null }>(request);
if (!body) return json({ error: "无效 JSON" }, { status: 400 });
const password = (body.password ?? "").toString().trim();
if (password) {
const passwordHash = await hashPassword(password);
await env.DB.prepare(
"UPDATE rooms SET is_private = 1, password_hash = ?, allow_anonymous = 1 WHERE id = ?",
)
.bind(passwordHash, lobby.id)
.run();
return json({ ok: true, mode: "private" });
}
await env.DB.prepare("UPDATE rooms SET is_private = 0, password_hash = NULL WHERE id = ?").bind(lobby.id).run();
return json({ ok: true, mode: "public" });
}
const patchUserMatch = pathname.match(/^\/api\/admin\/users\/([^/]+)$/);
if (patchUserMatch && request.method === "PATCH") {
const adminOrResp = await requireAdmin(env, request);
if (adminOrResp instanceof Response) return adminOrResp;
const userId = patchUserMatch[1]!;
const body = await readJson<{ level?: number; email?: string; qq?: string; phone?: string }>(request);
if (!body) return json({ error: "无效 JSON" }, { status: 400 });
const level = typeof body.level === "number" ? body.level : null;
if (level !== null && (level < 0 || level > 3)) return json({ error: "level 必须是 0-3" }, { status: 400 });
await env.DB.prepare(
"UPDATE users SET level = COALESCE(?, level), email = COALESCE(?, email), qq = COALESCE(?, qq), phone = COALESCE(?, phone) WHERE id = ?",
)
.bind(level, body.email?.trim() || null, body.qq?.trim() || null, body.phone?.trim() || null, userId)
.run();
return json({ ok: true });
}
if (patchUserMatch && request.method === "DELETE") {
const adminOrResp = await requireAdmin(env, request);
if (adminOrResp instanceof Response) return adminOrResp;
const userId = patchUserMatch[1]!;
// 保护:避免误删最后一个管理员
const adminCountRow = await env.DB.prepare("SELECT COUNT(1) as c FROM users WHERE level = 3").first<{ c: number }>();
const targetLevelRow = await env.DB.prepare("SELECT level FROM users WHERE id = ?").bind(userId).first<{ level: number }>();
if (!targetLevelRow) return json({ error: "用户不存在" }, { status: 404 });
if (targetLevelRow.level === 3 && (adminCountRow?.c ?? 0) <= 1) return forbidden("不能删除最后一个管理员");
// 删除用户保留历史消息sender_name 仍保留),仅将 user_id 置空
await env.DB.batch([
env.DB.prepare("UPDATE messages SET user_id = NULL WHERE user_id = ?").bind(userId),
env.DB.prepare("DELETE FROM bans WHERE user_id = ?").bind(userId),
env.DB.prepare("DELETE FROM password_resets WHERE user_id = ?").bind(userId),
env.DB.prepare("DELETE FROM users WHERE id = ?").bind(userId),
]);
return json({ ok: true });
}
const banMatch = pathname.match(/^\/api\/admin\/users\/([^/]+)\/ban$/);
if (banMatch && request.method === "POST") {
const adminOrResp = await requireAdmin(env, request);
if (adminOrResp instanceof Response) return adminOrResp;
const userId = banMatch[1]!;
const body = await readJson<{ reason?: string; minutes?: number }>(request);
const minutes = typeof body?.minutes === "number" ? body.minutes : null;
const until = minutes && minutes > 0 ? Date.now() + minutes * 60 * 1000 : null;
await env.DB.prepare("INSERT INTO bans (user_id, reason, until, created_at) VALUES (?, ?, ?, ?)")
.bind(userId, body?.reason?.trim() || null, until, Date.now())
.run();
return json({ ok: true });
}
const delMsgMatch = pathname.match(/^\/api\/admin\/messages\/(\d+)$/);
if (delMsgMatch && request.method === "DELETE") {
const adminOrResp = await requireAdmin(env, request);
if (adminOrResp instanceof Response) return adminOrResp;
const id = Number(delMsgMatch[1]!);
await env.DB.prepare("DELETE FROM messages WHERE id = ?").bind(id).run();
return json({ ok: true });
}
if (pathname === "/api/admin/debug/create-admin" && request.method === "POST") {
if (!isDevMode(env)) return forbidden("仅 DEV_MODE 可用");
const body = await readJson<{ username?: string; password?: string }>(request);
if (!body) return json({ error: "无效 JSON" }, { status: 400 });
const username = body.username ? sanitizeUsername(body.username) : null;
const password = (body.password ?? "").trim();
if (!username || password.length < 6) return json({ error: "参数错误" }, { status: 400 });
if (username.startsWith("游客")) return json({ error: "用户名不能以“游客”开头(该前缀保留给匿名游客)" }, { status: 400 });
const passwordHash = await hashPassword(password);
const id = crypto.randomUUID();
const now = Date.now();
await env.DB.prepare(
"INSERT INTO users (id, username, password_hash, email, qq, phone, level, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id, username, passwordHash, null, null, null, 3, now)
.run();
return json({ ok: true, id });
}
if (pathname === "/api/_clear_session" && request.method === "POST") {
const headers = new Headers();
setCookie(headers, clearCookie("sid", !isDevMode(env)));
return json({ ok: true }, { headers });
}
return notFound();
}

43
src/lib/cookies.ts Normal file
View File

@ -0,0 +1,43 @@
export function parseCookieHeader(headerValue: string | null): Record<string, string> {
const out: Record<string, string> = {};
if (!headerValue) return out;
for (const part of headerValue.split(";")) {
const [rawName, ...rawValue] = part.trim().split("=");
if (!rawName) continue;
out[rawName] = decodeURIComponent(rawValue.join("=") ?? "");
}
return out;
}
export function getCookie(request: Request, name: string): string | null {
const cookies = parseCookieHeader(request.headers.get("cookie"));
return cookies[name] ?? null;
}
export function setCookie(headers: Headers, cookie: string): void {
headers.append("set-cookie", cookie);
}
export function makeCookie(
name: string,
value: string,
options: {
httpOnly?: boolean;
secure?: boolean;
sameSite?: "Lax" | "Strict" | "None";
path?: string;
maxAgeSeconds?: number;
} = {},
): string {
const parts = [`${name}=${encodeURIComponent(value)}`];
parts.push(`Path=${options.path ?? "/"}`);
if (options.httpOnly) parts.push("HttpOnly");
if (options.secure ?? true) parts.push("Secure");
parts.push(`SameSite=${options.sameSite ?? "Lax"}`);
if (typeof options.maxAgeSeconds === "number") parts.push(`Max-Age=${Math.floor(options.maxAgeSeconds)}`);
return parts.join("; ");
}
export function clearCookie(name: string, secure = true): string {
return `${name}=; Path=/; Max-Age=0; SameSite=Lax;${secure ? " Secure;" : ""} HttpOnly`;
}

117
src/lib/crypto.ts Normal file
View File

@ -0,0 +1,117 @@
function base64FromBytes(bytes: Uint8Array): string {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
function bytesFromBase64(value: string): Uint8Array<ArrayBuffer> {
const binary = atob(value);
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
export function randomToken(bytes = 32): string {
const buf = new Uint8Array(new ArrayBuffer(bytes));
crypto.getRandomValues(buf);
return base64FromBytes(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
/**
*
* @param length 6
* @returns
*/
export function randomNumericCode(length = 6): string {
const digits = "0123456789";
let code = "";
const randomValues = new Uint8Array(length);
crypto.getRandomValues(randomValues);
for (let i = 0; i < length; i++) {
code += digits[randomValues[i] % 10];
}
return code;
}
// Cloudflare Workers PBKDF2 iterations have an upper bound; keep within supported range.
const PBKDF2_ITERATIONS = 100_000;
export async function hashPassword(password: string): Promise<string> {
const saltBytes = new Uint8Array(new ArrayBuffer(16));
crypto.getRandomValues(saltBytes);
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: saltBytes,
iterations: PBKDF2_ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
256,
);
const hashBytes = new Uint8Array(bits);
return ["pbkdf2", String(PBKDF2_ITERATIONS), base64FromBytes(saltBytes), base64FromBytes(hashBytes)].join("$");
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split("$");
let algo: string;
let iterStr: string;
let saltB64: string;
let hashB64: string;
// Backward/forward compatibility:
// - Current format: pbkdf2$150000$<saltB64>$<hashB64>
// - Legacy (buggy parser expectation): pbkdf2$150000$<ignored>$<saltB64>$<ignored>$<hashB64>
if (parts.length === 4) {
[algo, iterStr, saltB64, hashB64] = parts;
} else if (parts.length === 6) {
[algo, iterStr, , saltB64, , hashB64] = parts;
} else {
return false;
}
if (algo !== "pbkdf2") return false;
const iterations = Number(iterStr);
if (!Number.isFinite(iterations) || iterations < 1) return false;
if (iterations > PBKDF2_ITERATIONS) return false;
const saltBytes = bytesFromBase64(saltB64);
const expectedHash = bytesFromBase64(hashB64);
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: saltBytes,
iterations,
hash: "SHA-256",
},
keyMaterial,
expectedHash.length * 8,
);
const actual = new Uint8Array(bits);
if (actual.length !== expectedHash.length) return false;
let mismatch = 0;
for (let i = 0; i < actual.length; i++) mismatch |= actual[i] ^ expectedHash[i];
return mismatch === 0;
}

172
src/lib/email.ts Normal file
View File

@ -0,0 +1,172 @@
import { Resend } from "resend";
import type { Env } from "../env";
/**
*
* @param env
* @param to
* @param resetToken
* @param username
* @returns
*/
export async function sendPasswordResetEmail(
env: Env,
to: string,
resetToken: string,
username: string
): Promise<{ success: boolean; error?: string }> {
const apiKey = env.RESEND_API_KEY;
const fromEmail = env.RESEND_FROM_EMAIL || "noreply@yourdomain.com";
if (!apiKey) {
return {
success: false,
error: "RESEND_API_KEY 未配置,请运行: npx wrangler secret put RESEND_API_KEY",
};
}
try {
const resend = new Resend(apiKey);
// 发送邮件
const { data, error } = await resend.emails.send({
from: fromEmail,
to: [to],
subject: "密码重置验证码 - 聊天室",
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
color: #2c3e50;
margin: 0;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
}
.code-box {
background-color: #f8f9fa;
border: 2px dashed #007bff;
border-radius: 8px;
padding: 20px;
margin: 25px 0;
text-align: center;
}
.verification-code {
font-size: 32px;
font-weight: bold;
color: #007bff;
letter-spacing: 4px;
font-family: 'Courier New', monospace;
user-select: all;
}
.footer {
text-align: center;
color: #666;
font-size: 14px;
margin-top: 20px;
}
.warning {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 12px;
margin: 15px 0;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔐 </h1>
</div>
<div class="content">
<p><strong>${username}</strong></p>
<p>使</p>
<div class="code-box">
<div style="color: #666; font-size: 14px; margin-bottom: 10px;"></div>
<div class="verification-code">${resetToken}</div>
</div>
<p style="text-align: center; color: #666; font-size: 14px;">
</p>
<div class="warning">
<strong> </strong>
<ul style="margin: 10px 0;">
<li> <strong>30 </strong></li>
<li></li>
<li></li>
</ul>
</div>
</div>
<div class="footer">
<p></p>
<p style="color: #999; font-size: 12px;">© 2026 </p>
</div>
</div>
</body>
</html>
`,
text: `
${username}
使
${resetToken}
- 30
-
-
`.trim(),
});
if (error) {
console.error("Resend 发送邮件失败:", error);
return {
success: false,
error: error.message || "邮件发送失败",
};
}
console.log("密码重置邮件已发送:", data);
return { success: true };
} catch (error) {
console.error("发送邮件时出错:", error);
return {
success: false,
error: error instanceof Error ? error.message : "未知错误",
};
}
}

30
src/lib/http.ts Normal file
View File

@ -0,0 +1,30 @@
export function json(data: unknown, init?: ResponseInit): Response {
const headers = new Headers(init?.headers);
headers.set("content-type", "application/json; charset=utf-8");
return new Response(JSON.stringify(data), { ...init, headers });
}
export function badRequest(message: string): Response {
return json({ error: message }, { status: 400 });
}
export function unauthorized(message = "未登录"): Response {
return json({ error: message }, { status: 401 });
}
export function forbidden(message = "无权限"): Response {
return json({ error: message }, { status: 403 });
}
export function notFound(): Response {
return json({ error: "Not found" }, { status: 404 });
}
export async function readJson<T = unknown>(request: Request): Promise<T | null> {
try {
return (await request.json()) as T;
} catch {
return null;
}
}

31
src/lib/roomAccess.ts Normal file
View File

@ -0,0 +1,31 @@
import type { Env } from "../env";
import { getCache, getNumberVar } from "../env";
import { randomToken } from "./crypto";
export type RoomAccess = {
userId?: string;
nickname?: string;
};
export async function createRoomAccessToken(
env: Env,
roomId: string,
value: RoomAccess,
): Promise<{ token: string; ttlSeconds: number }> {
const token = randomToken(24);
const ttlSeconds = getNumberVar(env, "ROOM_ACCESS_TTL_SECONDS", 60 * 60 * 24);
const kv = getCache(env);
await kv.put(`room_access:${roomId}:${token}`, JSON.stringify(value), { expirationTtl: ttlSeconds });
return { token, ttlSeconds };
}
export async function verifyRoomAccessToken(env: Env, roomId: string, token: string): Promise<RoomAccess | null> {
const kv = getCache(env);
const raw = await kv.get(`room_access:${roomId}:${token}`);
if (!raw) return null;
try {
return JSON.parse(raw) as RoomAccess;
} catch {
return null;
}
}

39
src/lib/sessions.ts Normal file
View File

@ -0,0 +1,39 @@
import type { Env } from "../env";
import { getCache, getNumberVar, isDevMode } from "../env";
import { getCookie, makeCookie } from "./cookies";
import { randomToken } from "./crypto";
const SESSION_COOKIE = "sid";
export type Session = {
sid: string;
userId: string;
};
export async function createSession(env: Env, userId: string): Promise<{ sid: string; setCookie: string }> {
const sid = randomToken(32);
const ttl = getNumberVar(env, "SESSION_TTL_SECONDS", 60 * 60 * 24 * 7);
const kv = getCache(env);
await kv.put(`session:${sid}`, userId, { expirationTtl: ttl });
return {
sid,
setCookie: makeCookie(SESSION_COOKIE, sid, { httpOnly: true, maxAgeSeconds: ttl, secure: !isDevMode(env) }),
};
}
export async function getSession(env: Env, request: Request): Promise<Session | null> {
const sid = getCookie(request, SESSION_COOKIE);
if (!sid) return null;
const kv = getCache(env);
const userId = await kv.get(`session:${sid}`);
if (!userId) return null;
return { sid, userId };
}
export async function destroySession(env: Env, request: Request): Promise<string | null> {
const sid = getCookie(request, SESSION_COOKIE);
if (!sid) return null;
const kv = getCache(env);
await kv.delete(`session:${sid}`);
return makeCookie(SESSION_COOKIE, "", { httpOnly: true, maxAgeSeconds: 0, secure: !isDevMode(env) });
}

15
src/lib/sleep.ts Normal file
View File

@ -0,0 +1,15 @@
export async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return;
await new Promise<void>((resolve) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(t);
resolve();
},
{ once: true },
);
});
}

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "WebWorker"],
"module": "ES2022",
"moduleResolution": "Bundler",
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

30
wrangler.toml Normal file
View File

@ -0,0 +1,30 @@
name = "cf-chat"
main = "src/index.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
[vars]
SESSION_TTL_SECONDS = "604800"
ROOM_ACCESS_TTL_SECONDS = "86400"
DEV_MODE = "false"
# Resend 邮件配置
RESEND_FROM_EMAIL = "noreply@zxd.im" # 修改为你的发件人邮箱
# RESEND_API_KEY 应该使用 secret 设置,运行: npx wrangler secret put RESEND_API_KEY
[[d1_databases]]
binding = "DB"
database_name = "douban"
database_id = "dea24df8-6551-473e-9b1e-b2f2e2211090"
[[kv_namespaces]]
binding = "CACHE"
id = "56ef01a9d92e42688e91a75bc9a7c534"
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "paimian"
[assets]
directory = "./public"
binding = "ASSETS"
not_found_handling = "single-page-application"