import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { cookies } from "next/headers"; const AUTH_ROOT = path.join( /* turbopackIgnore: true */ process.cwd(), "../../.port_sessions/accounts", ); const USERS_PATH = path.join(AUTH_ROOT, "users.json"); const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json"); const COOKIE_NAME = "claw_account_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000; type UserRecord = { id: string; username: string; passwordHash: string; createdAt: string; }; type UsersFile = { users: UserRecord[]; }; type SessionsFile = { sessions: Record< string, { accountId: string; createdAt: string; updatedAt?: string } >; }; export type AccountSession = { id: string; username: string; }; export async function registerAccount(username: string, password: string) { const cleanUsername = normalizeUsername(username); validatePassword(password); const usersFile = await readUsers(); if (usersFile.users.some((user) => user.username === cleanUsername)) { throw new Error("账号已存在"); } const account: UserRecord = { id: cleanUsername, username: cleanUsername, passwordHash: hashPassword(password), createdAt: new Date().toISOString(), }; usersFile.users.push(account); await writeUsers(usersFile); await createAccountDirectories(account.id); return createSession(account); } export async function loginAccount(username: string, password: string) { const cleanUsername = normalizeUsername(username); const usersFile = await readUsers(); const account = usersFile.users.find( (user) => user.username === cleanUsername, ); if (!account || !verifyPassword(password, account.passwordHash)) { throw new Error("账号或密码错误"); } await createAccountDirectories(account.id); return createSession(account); } const XIAOMI_EMAIL_RE = /^([\w.-]+)@xiaomi\.com$/i; export function accountIdFromEmail(email: string): string { const match = XIAOMI_EMAIL_RE.exec((email ?? "").trim()); if (!match) { throw new Error("请使用小米邮箱(@xiaomi.com)登录"); } const prefix = match[1].toLowerCase(); if (!/^[a-z0-9._-]{2,40}$/.test(prefix)) { throw new Error( "邮箱前缀只能包含小写字母、数字、点、下划线或中划线,长度 2-40", ); } return prefix; } export async function registerByEmail(email: string, password: string) { const accountId = accountIdFromEmail(email); validatePassword(password); const usersFile = await readUsers(); if (usersFile.users.some((user) => user.id === accountId)) { throw new Error("该邮箱已注册,请直接登录"); } const account: UserRecord = { id: accountId, username: accountId, passwordHash: hashPassword(password), createdAt: new Date().toISOString(), }; usersFile.users.push(account); await writeUsers(usersFile); await createAccountDirectories(account.id); return createSession(account); } export async function loginByEmail(email: string, password: string) { const accountId = accountIdFromEmail(email); const usersFile = await readUsers(); const account = usersFile.users.find((user) => user.id === accountId); if (!account) { throw new Error("账号不存在,请先注册"); } if (!account.passwordHash) { throw new Error("该账号尚未设置密码,请联系管理员重置"); } if (!verifyPassword(password, account.passwordHash)) { throw new Error("邮箱或密码错误"); } await createAccountDirectories(account.id); return createSession(account); } export async function logoutAccount() { const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; if (token) { const sessionsFile = await readSessions(); delete sessionsFile.sessions[token]; await writeSessions(sessionsFile); } cookieStore.delete(COOKIE_NAME); } export async function getCurrentAccount(): Promise { const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; if (!token) return null; const sessionsFile = await readSessions(); const session = sessionsFile.sessions[token]; if (!session) return null; const usersFile = await readUsers(); const account = usersFile.users.find((user) => user.id === session.accountId); if (!account) return null; if (shouldRefreshSession(session.updatedAt ?? session.createdAt)) { session.updatedAt = new Date().toISOString(); await writeSessions(sessionsFile); await setSessionCookie(token); } return { id: account.id, username: account.username }; } export function accountUploadRoot(accountId: string) { return path.join(AUTH_ROOT, accountId, "sessions"); } export function accountOutputRoot(accountId: string) { return path.join(AUTH_ROOT, accountId, "sessions"); } export function accountBaseRoot(accountId: string) { return path.join(AUTH_ROOT, accountId); } export function accountSessionInputRoot(accountId: string, sessionId: string) { return path.join(accountBaseRoot(accountId), "sessions", sessionId, "input"); } export function accountSessionOutputRoot(accountId: string, sessionId: string) { return path.join(accountBaseRoot(accountId), "sessions", sessionId, "output"); } export function accountSessionScratchpadRoot( accountId: string, sessionId: string, ) { return path.join( accountBaseRoot(accountId), "sessions", sessionId, "scratchpad", ); } export function accountSessionRoot(accountId: string, sessionId: string) { return path.join(accountBaseRoot(accountId), "sessions", sessionId); } async function createSession(account: UserRecord) { const token = randomBytes(32).toString("hex"); const sessionsFile = await readSessions(); sessionsFile.sessions[token] = { accountId: account.id, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await writeSessions(sessionsFile); await setSessionCookie(token); return { id: account.id, username: account.username }; } async function setSessionCookie(token: string) { const cookieStore = await cookies(); cookieStore.set(COOKIE_NAME, token, { httpOnly: true, sameSite: "lax", path: "/", maxAge: SESSION_MAX_AGE_SECONDS, }); } async function createAccountDirectories(accountId: string) { await Promise.all( ["sessions"].map((name) => mkdir(path.join(AUTH_ROOT, accountId, name), { recursive: true }), ), ); } async function readUsers(): Promise { await mkdir(AUTH_ROOT, { recursive: true }); try { return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile; } catch { return { users: [] }; } } async function writeUsers(payload: UsersFile) { await mkdir(AUTH_ROOT, { recursive: true }); await writeFile(USERS_PATH, JSON.stringify(payload, null, 2), "utf8"); } async function readSessions(): Promise { await mkdir(AUTH_ROOT, { recursive: true }); try { return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile; } catch { return { sessions: {} }; } } async function writeSessions(payload: SessionsFile) { await mkdir(AUTH_ROOT, { recursive: true }); await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8"); } function normalizeUsername(username: string) { const cleanUsername = username.trim().toLowerCase(); if (!/^[a-z0-9._-]{2,40}$/.test(cleanUsername)) { throw new Error( "账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40", ); } return cleanUsername; } function validatePassword(password: string) { if (password.length < 6) throw new Error("密码至少 6 位"); } function hashPassword(password: string) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(password, salt, 64).toString("hex"); return `${salt}:${hash}`; } function verifyPassword(password: string, passwordHash: string) { const [salt, expectedHash] = passwordHash.split(":"); if (!salt || !expectedHash) return false; const actual = Buffer.from(scryptSync(password, salt, 64).toString("hex")); const expected = Buffer.from(expectedHash); return actual.length === expected.length && timingSafeEqual(actual, expected); } function shouldRefreshSession(value?: string) { if (!value) return true; const timestamp = Date.parse(value); if (Number.isNaN(timestamp)) return true; return Date.now() - timestamp > SESSION_REFRESH_INTERVAL_MS; }