归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
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
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
This commit is contained in:
1 parent
855a001046
commit
a74b3c7127
98 files changed
+15657
No files matched your search
@@ -0,0 +1,255 @@
|
||||
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 未配置');
|
||||
}
|
||||
Reference in new issue
Block a user