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