first commit
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
export type Env = {
|
||||
DB: D1Database;
|
||||
CACHE?: KVNamespace;
|
||||
MEMOS_CACHE?: KVNamespace;
|
||||
BUCKET: R2Bucket;
|
||||
ASSETS: Fetcher;
|
||||
SESSION_TTL_SECONDS?: string;
|
||||
ROOM_ACCESS_TTL_SECONDS?: string;
|
||||
DEV_MODE?: string;
|
||||
RESEND_API_KEY?: string;
|
||||
RESEND_FROM_EMAIL?: string;
|
||||
};
|
||||
|
||||
export function getCache(env: Env): KVNamespace {
|
||||
const kv = env.CACHE ?? env.MEMOS_CACHE;
|
||||
if (!kv) throw new Error('KV 未绑定:请在 wrangler.toml 里绑定 "CACHE"');
|
||||
return kv;
|
||||
}
|
||||
|
||||
export function getNumberVar(env: Env, key: keyof Env, fallback: number): number {
|
||||
const value = env[key];
|
||||
if (typeof value !== "string") return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function isDevMode(env: Env): boolean {
|
||||
return String(env.DEV_MODE ?? "").toLowerCase() === "true";
|
||||
}
|
||||
+748
@@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user