Files
blog/blog-admin/src/routes/user.ts
T
zqlit a74b3c7127
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped
归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
2026-10-04 08:45:40 +08:00

256 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 未配置');
}