255 lines
8.9 KiB
TypeScript
255 lines
8.9 KiB
TypeScript
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 未配置');
|
|||
|
|
}
|