import type { Env, UserRow } from '../types'; import { findUserByEmail, findUserByName, findUserByNameEmail, rateLimit } from '../lib/db'; import { cookNotify, cookUser } from '../lib/cook'; import { isAdminByNameEmail, signToken, verifyPassword } from '../lib/session'; import { fail, getClientIP, isEmail, now, ok, okMsg, qp, readBody, trimTo } from '../lib/util'; import type { Ctx } from '../router'; // ==================================================================== GET /user export async function userInfo(ctx: Ctx): Promise { const { env, url, user } = ctx; const name = qp(url, 'name'); const email = qp(url, 'email'); // 官方语义:name+email 即凭证(sidebar 用这种),email 是调用者已知的 // 信息,不构成泄露;token 优先。 let target: UserRow | null = user; if (!target && name && email) { target = await findUserByNameEmail(env, name, email); } if (!target) { return ok({ user: null, is_login: false, notifies: [], notifies_count: 0 }); } const notifies = await env.DB.prepare( `SELECT n.*, c.page_key FROM notifies n LEFT JOIN comments c ON c.id = n.comment_id WHERE n.user_id = ? AND n.is_read = 0 ORDER BY n.created_at DESC LIMIT 20`, ) .bind(target.id) .all<{ id: number; user_id: number; comment_id: number; is_read: number; is_emailed: number; page_key: string | null; }>(); const list = (notifies.results ?? []).map((n) => cookNotify(n, notifyAnchor(n.page_key, n.comment_id)), ); return ok({ user: cookUser(target), is_login: !!user && user.id === target.id, notifies: list, notifies_count: list.length, }); } /** 官方 read_link 语义:跳回文章页定位到那条评论 */ function notifyAnchor(pageKey: string | null, commentId: number): string { const key = (pageKey || '').trim(); return key ? `${key}?atk_comment=${commentId}` : `/admin/comments?comment_id=${commentId}`; } // =================================================================== POST /user export async function userUpdate(ctx: Ctx): Promise { const { env, req, user } = ctx; const body = await readBody(req); const name = trimTo(body.name || '', 60).trim(); const email = trimTo(body.email || '', 120).trim(); const link = trimTo(body.link || '', 255).trim(); if (!name || !email) return fail(400, 'name and email are required'); if (!isEmail(email)) return fail(400, 'Invalid Email'); // 必须登录且只能改自己;未登录时代官方的 name+email 匹配也允许 let target: UserRow | null = user; if (!target) { target = await findUserByNameEmail(env, name, email); if (!target) return fail(401, 'Login required'); } else if (target.name !== name || target.email !== email) { return fail(403, 'Forbidden'); } await env.DB.prepare('UPDATE users SET name = ?, email = ?, link = ?, updated_at = ? WHERE id = ?') .bind(name, email, link, now(), target.id) .run(); const updated = await env.DB.prepare('SELECT * FROM users WHERE id = ?') .bind(target.id) .first(); return ok({ user: cookUser(updated as UserRow) }); } // ========================================================== GET /user/status export async function userStatus(ctx: Ctx): Promise { const { env, url, user } = ctx; const name = qp(url, 'name'); const email = qp(url, 'email'); if (user) { return ok({ is_admin: !!user.is_admin, is_login: true }); } if (name && email) { return ok({ is_admin: await isAdminByNameEmail(env, name, email), is_login: false }); } return ok({ is_admin: false, is_login: false }); } /** * 登录标识:可能是邮箱(user@x.com),也可能是用户名(admin)。 * 后台登录框只有一个「邮箱」字段,用户很可能把账号名直接填进去, * 所以这里对两种写法都做匹配,避免"账号明明是对的却登不上"。 */ async function findLoginCandidates( env: Env, identifier: string, name?: string, ): Promise { const id = (identifier || '').trim(); const nm = (name || '').trim(); if (id && id.includes('@')) { const byEmail = await findUserByEmail(env, id); return nm ? byEmail.filter((u) => u.name === nm) : byEmail; } // 不是邮箱形态 → 当成用户名;也顺带用 name 参数兜一下 const names = [...new Set([id, nm].filter(Boolean))]; const out: UserRow[] = []; for (const n of names) out.push(...(await findUserByName(env, n))); // 万一有人把用户名写成了邮箱格式的账号,再补一次邮箱匹配 if (!out.length && id) out.push(...(await findUserByEmail(env, id))); return out; } // =================================================== POST /user/access_token export async function userAccessToken(ctx: Ctx): Promise { const { env, req } = ctx; const ip = getClientIP(req); if (!(await rateLimit(env, `login:${ip}`, 10, 300))) { return fail(429, 'Too many login attempts, try again later'); } const body = await readBody(req); const identifier = trimTo(body.email || body.username || body.account || '', 120).trim(); const name = trimTo(body.name || '', 60).trim(); const password = String(body.password ?? ''); if (!identifier && !name) return fail(400, '账号不能为空'); if (!password) return fail(400, '密码不能为空'); const candidates = await findLoginCandidates(env, identifier, name); if (!candidates.length) return fail(401, 'Unauthorized'); for (const u of candidates) { if (await verifyPassword(u.password, password)) { return ok({ token: await signToken(env, u.id), user: cookUser(u) }); } } return fail(401, 'Unauthorized'); } // ==================================================== POST /auth/email/login export async function authEmailLogin(ctx: Ctx): Promise { const { env, req } = ctx; const ip = getClientIP(req); if (!(await rateLimit(env, `login:${ip}`, 10, 300))) { return fail(429, 'Too many login attempts, try again later'); } const body = await readBody(req); const identifier = trimTo(body.email || body.username || body.account || '', 120).trim(); const password = String(body.password ?? ''); const code = String(body.code ?? '').trim(); if (code) { return fail( 501, '邮箱验证码登录未启用:Cloudflare Workers 没有 SMTP,发不出验证邮件。请改用密码登录。', ); } if (!identifier || !password) return fail(400, '账号和密码不能为空'); for (const u of await findLoginCandidates(env, identifier)) { if (await verifyPassword(u.password, password)) { return ok({ token: await signToken(env, u.id), user: cookUser(u) }); } } return fail(401, 'Unauthorized'); } export async function authEmailSend(ctx: Ctx): Promise { // Workers 无法直连 SMTP。要恢复「邮箱验证码」,接一个 HTTP 邮件 API // (Resend / MailChannels / 你自己的转发接口)后在 sendMail() 里实现。 return fail( 501, '邮件发送未启用:Workers 环境没有 SMTP,需要接 Resend / MailChannels 之类的 HTTP 邮件 API。', ); } export async function authEmailRegister(ctx: Ctx): Promise { return fail(403, '注册已关闭:本服务端只保留「昵称 + 邮箱直接评论」和「管理员密码登录」。'); } // ============================================================== auth/merge export async function authMergeCheck(ctx: Ctx): Promise { const { env, user } = ctx; if (!user) return fail(401, 'Login required'); const same = await findUserByEmail(env, user.email); const names = same.filter((u) => u.id !== user.id).map((u) => u.name); return ok({ need_merge: names.length > 0, user_names: names }); } export async function authMergeApply(ctx: Ctx): Promise { const { env, req, user } = ctx; if (!user) return fail(401, 'Login required'); const body = await readBody(req); const fromName = trimTo(body.user_name || '', 60).trim(); if (!fromName) return fail(400, 'user_name is required'); const from = await findUserByNameEmail(env, fromName, user.email); if (!from || from.id === user.id) return okMsg('Nothing to merge'); const updated = await env.DB.prepare( 'UPDATE comments SET user_id = ? WHERE user_id = ?', ) .bind(user.id, from.id) .run(); await env.DB.prepare('UPDATE notifies SET user_id = ? WHERE user_id = ?') .bind(user.id, from.id) .run(); await env.DB.prepare('UPDATE votes SET user_id = ? WHERE user_id = ?') .bind(user.id, from.id) .run(); await env.DB.prepare('DELETE FROM users WHERE id = ?').bind(from.id).run(); return ok({ deleted_user_count: 1, update_comments_count: updated.meta?.changes ?? 0, update_notifies_count: 0, update_votes_count: 0, user_token: await signToken(env, user.id), }); } // ============================================================ sso/exchange export async function ssoExchange(): Promise { return fail(501, 'SSO 未配置'); }