first commit
This commit is contained in:
commit
7e927accac
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
.wrangler
|
||||
.claude
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Cloudflare 在线聊天室(D1 + KV + R2)
|
||||
|
||||
按 `readme.txt` 需求实现的最小可用在线聊天室:D1 存用户/房间/消息,KV 做会话与房间访问令牌缓存,R2 存图片。
|
||||
|
||||
## 功能概览
|
||||
|
||||
- 房间模式:开放/私密
|
||||
- 当前版本只有一个房间:大厅(可选设置密码)
|
||||
- 开放:所有人可浏览;必须注册登录才能发言
|
||||
- 私密:需要密码进入;可选“允许游客匿名发言”
|
||||
- 用户等级:匿名游客 / 注册会员 / 认证会员 / 管理员
|
||||
- 注册会员:可发文字/表情;不可上传图片
|
||||
- 认证会员:可上传图片(R2)
|
||||
- 管理员:可编辑用户资料与等级、封禁用户、删除消息
|
||||
- 页面:注册、登录、找回密码、房间列表、聊天窗口(左右气泡)
|
||||
- 实时:SSE(EventSource)推送(简化实现,适合 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\"}"
|
||||
```
|
||||
|
||||
管理员可在页面右侧“管理 → 大厅密码”设置/取消大厅密码:设置后游客登录会要求输入该密码;取消后游客登录直接进入。
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 : "未知错误",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) });
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue