Files
blog/blog-admin/src/routes/user.ts
T

255 lines
8.9 KiB
TypeScript
Raw Normal View History

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<Response> {
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<Response> {
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<UserRow>();
return ok({ user: cookUser(updated as UserRow) });
}
// ========================================================== GET /user/status
export async function userStatus(ctx: Ctx): Promise<Response> {
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<UserRow[]> {
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<Response> {
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<Response> {
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<Response> {
// Workers 无法直连 SMTP。要恢复「邮箱验证码」,接一个 HTTP 邮件 API
// (Resend / MailChannels / 你自己的转发接口)后在 sendMail() 里实现。
return fail(
501,
'邮件发送未启用:Workers 环境没有 SMTP,需要接 Resend / MailChannels 之类的 HTTP 邮件 API。',
);
}
export async function authEmailRegister(ctx: Ctx): Promise<Response> {
return fail(403, '注册已关闭:本服务端只保留「昵称 + 邮箱直接评论」和「管理员密码登录」。');
}
// ============================================================== auth/merge
export async function authMergeCheck(ctx: Ctx): Promise<Response> {
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<Response> {
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<Response> {
return fail(501, 'SSO 未配置');
}