๐Ÿ“„ user.ts  โ€ข  9403 bytes
/**
 * CmdCode V0.5 - ็”จๆˆท่ฎค่ฏไธŽๅทฅไฝœๅŒบ็ฎก็†
 * 
 * ๅŠŸ่ƒฝ๏ผš
 *   1. ๆณจๅ†Œ/็™ปๅฝ• โ†’ ่ฟœ็จ‹PHP API + MySQL
 *   2. TokenๆœฌๅœฐๅŠ ๅฏ†็ผ“ๅญ˜ โ†’ ไธ‹ๆฌกๅฏๅŠจ่‡ชๅŠจ็™ปๅฝ•
 *   3. ๆ–ญ็‚น็ปญไผ  โ†’ ๅทฅไฝœๅŒบๅฟซ็…งไฟๅญ˜/ๆขๅค
 *   4. ็”จๆˆทไธ“ๅฑžๆ–‡ไปถๅคน โ†’ ้š”็ฆป+1GB้…้ข
 */
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
import { encrypt, decrypt } from './crypto-util.js'

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ้…็ฝฎ
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

const API_BASE = 'https://cmdcode.cn/xiaoc/cmdcode_api.php'
const CMD_DIR = join(homedir(), '.cmdcode')
const USER_CACHE_FILE = join(CMD_DIR, 'user.enc')
const WORKSPACE_BASE = join(CMD_DIR, 'workspaces')
// ้…้ข่ฎก็ฎ—๏ผšadmin 1GB๏ผŒๆ™ฎ้€š็”จๆˆท100MB
const SUPER_USERS = ['admin', 'root', 'administrator']
function getUserQuotaMb(username: string): number {
  return SUPER_USERS.includes(username) ? 1024 : 100
}

export { getUserQuotaMb }

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ็ฑปๅž‹
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

export interface UserInfo {
  username: string
  token: string
  expiresAt: string
  workspaceDir: string
}

export interface QuotaInfo {
  usedMb: number
  quotaMb: number
  remainingMb: number
  usagePercent: number
}

export interface SaveSnapshotResult {
  success: boolean
  message: string
}

/** ็”จๆˆท่‡ชๅฎšไน‰ๆจกๅž‹้…็ฝฎ */
export interface UserModel {
  id: string
  name: string
  model: string
  baseUrl: string
  apiKey: string
  note1?: string
  note2?: string
  createdAt: string
  isDefault: boolean
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ่ฟœ็จ‹API่ฐƒ็”จ
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

async function apiCall(action: string, data?: Record<string, string>, token?: string): Promise<any> {
  const url = `${API_BASE}?action=${action}`
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
  }
  if (token) {
    headers['Authorization'] = `Bearer ${token}`
  }

  const response = await fetch(url, {
    method: 'POST',
    headers,
    body: data ? JSON.stringify(data) : undefined,
    signal: AbortSignal.timeout(10_000), // 10็ง’่ถ…ๆ—ถ
  })

  return await response.json()
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ็”จๆˆทๆณจๅ†Œ
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

export async function register(username: string, password: string): Promise<UserInfo> {
  const result = await apiCall('register', { username, password })

  if (result.error) {
    throw new Error(result.error)
  }

  const userInfo: UserInfo = {
    username: result.username,
    token: result.token,
    expiresAt: result.expires_at,
    workspaceDir: join(WORKSPACE_BASE, result.username),
  }

  // ๅˆ›ๅปบ็”จๆˆทไธ“ๅฑžๆ–‡ไปถๅคน
  if (!existsSync(userInfo.workspaceDir)) {
    mkdirSync(userInfo.workspaceDir, { recursive: true })
  }

  // ๆœฌๅœฐๅŠ ๅฏ†็ผ“ๅญ˜token
  saveUserCache(userInfo)

  return userInfo
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ็”จๆˆท็™ปๅฝ•
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

export async function login(username: string, password: string): Promise<UserInfo> {
  const result = await apiCall('login', { username, password })

  if (result.error) {
    throw new Error(result.error)
  }

  const userInfo: UserInfo = {
    username: result.username,
    token: result.token,
    expiresAt: result.expires_at,
    workspaceDir: join(WORKSPACE_BASE, result.username),
  }

  // ๅˆ›ๅปบ็”จๆˆทไธ“ๅฑžๆ–‡ไปถๅคน
  if (!existsSync(userInfo.workspaceDir)) {
    mkdirSync(userInfo.workspaceDir, { recursive: true })
  }

  // ๆœฌๅœฐๅŠ ๅฏ†็ผ“ๅญ˜token
  saveUserCache(userInfo)

  return userInfo
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ๆ–ญ็‚น็ปญไผ ๏ผšไฟๅญ˜ๅทฅไฝœๅŒบๅฟซ็…ง
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

export async function saveWorkspaceSnapshot(
  userInfo: UserInfo,
  snapshot: string
): Promise<SaveSnapshotResult> {
  const result = await apiCall('save', { snapshot }, userInfo.token)

  if (result.error) {
    return { success: false, message: result.error }
  }

  return { success: true, message: result.message }
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ๆ–ญ็‚น็ปญไผ ๏ผšๆขๅคๅทฅไฝœๅŒบๅฟซ็…ง
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

export async function restoreWorkspaceSnapshot(userInfo: UserInfo): Promise<string | null> {
  const result = await apiCall('restore', undefined, userInfo.token)

  if (result.error) {
    return null
  }

  return result.snapshot || null
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ้…้ขๆŸฅ่ฏข
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

export async function getQuota(userInfo: UserInfo): Promise<QuotaInfo> {
  const result = await apiCall('quota', undefined, userInfo.token)

  if (result.error) {
    return { usedMb: 0, quotaMb: getUserQuotaMb(userInfo.username), remainingMb: getUserQuotaMb(userInfo.username), usagePercent: 0 }
  }

  return {
    usedMb: result.used_mb,
    quotaMb: result.quota_mb,
    remainingMb: result.remaining_mb,
    usagePercent: result.usage_percent,
  }
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ๆ›ดๆ–ฐ่ฟœ็จ‹้…้ข
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

export async function updateRemoteQuota(userInfo: UserInfo, usedMb: number): Promise<void> {
  await apiCall('update_quota', { used_mb: String(usedMb) }, userInfo.token)
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ๆœฌๅœฐToken็ผ“ๅญ˜๏ผˆๅŠ ๅฏ†ๅญ˜ๅ‚จ๏ผ‰
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

function saveUserCache(userInfo: UserInfo): void {
  if (!existsSync(CMD_DIR)) mkdirSync(CMD_DIR, { recursive: true })
  const data = {
    username: userInfo.username,
    token: userInfo.token,
    expiresAt: userInfo.expiresAt,
  }
  writeFileSync(USER_CACHE_FILE, encrypt(data), 'utf-8')
  try { require('node:fs').chmodSync(USER_CACHE_FILE, 0o600) } catch { /* ignore */ }
}

export function loadUserCache(): UserInfo | null {
  if (!existsSync(USER_CACHE_FILE)) return null
  try {
    const data = decrypt<{ username: string; token: string; expiresAt: string }>(
      readFileSync(USER_CACHE_FILE, 'utf-8')
    )
    return {
      username: data.username,
      token: data.token,
      expiresAt: data.expiresAt,
      workspaceDir: join(WORKSPACE_BASE, data.username),
    }
  } catch {
    return null
  }
}

export function clearUserCache(): void {
  if (existsSync(USER_CACHE_FILE)) {
    require('node:fs').unlinkSync(USER_CACHE_FILE)
  }
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// Tokenๆœ‰ๆ•ˆๆ€ง้ชŒ่ฏ
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

export async function validateToken(userInfo: UserInfo): Promise<boolean> {
  try {
    const result = await apiCall('quota', undefined, userInfo.token)
    return result.success === true
  } catch {
    return false
  }
}

/** ไป…ๅ‡ญ token ๅญ—็ฌฆไธฒ้ชŒ่ฏๆœ‰ๆ•ˆๆ€ง๏ผˆๆ— ้œ€ UserInfo๏ผŒ็”จไบŽ Web ๅคš็”จๆˆทๅœบๆ™ฏ๏ผ‰ */
export async function verifyAuthToken(token: string): Promise<UserInfo | null> {
  try {
    const result = await apiCall('quota', undefined, token)
    if (result.success === true && result.username) {
      return {
        username: result.username,
        token,
        expiresAt: result.expires_at || '',
        workspaceDir: getWorkspaceDir(result.username),
      }
    }
    return null
  } catch {
    return null
  }
}

// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
// ่Žทๅ–็”จๆˆทๅทฅไฝœๅŒบ่ทฏๅพ„
// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

export function getWorkspaceDir(username: string): string {
  return join(WORKSPACE_BASE, username)
}