Ctrl K
ComunidadGuías…

desktop/electron/serverConfig.cjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'use strict'

/**
 * 桌面壳「多服务器 baseUrl」配置:纯逻辑,可单测,不依赖 Electron。
 * 约定:配置项为站点根(无尾斜杠),API/更新源由此推导。
 */

const fs = require('fs')
const path = require('path')

const DEFAULT_SERVER_BASE_URL = 'https://mao.example.com'
const CONFIG_VERSION = 1
const UPDATE_MODES = ['follow-site', 'package-default', 'disabled']

function stripTrailingSlash(url) {
  return String(url || '').replace(/\/+$/, '')
}

/**
 * 归一化用户输入为站点根。
 * 接受:站点根 / /api / /api/v1;无协议时默认 https。
 * @param {string} input
 * @param {{ allowHttp?: boolean }} [options]
 * @returns {{ ok: true, baseUrl: string } | { ok: false, error: string }}
 */
function normalizeServerBaseUrl(input, options = {}) {
  const allowHttp = options.allowHttp === true
  const raw = String(input || '').trim()
  if (!raw) return { ok: false, error: '请填写服务器地址' }

  let candidate = raw
  if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(candidate)) {
    candidate = `https://${candidate}`
  }

  let parsed
  try {
    parsed = new URL(candidate)
  } catch {
    return { ok: false, error: '服务器地址格式无效' }
  }

  if (parsed.username || parsed.password) {
    return { ok: false, error: '服务器地址不能包含账号密码' }
  }
  if (parsed.protocol === 'https:') {
    // ok
  } else if (parsed.protocol === 'http:') {
    if (!allowHttp) {
      return { ok: false, error: '默认仅支持 HTTPS;内网 HTTP 请确认后开启允许' }
    }
  } else {
    return { ok: false, error: `不支持的协议:${parsed.protocol}` }
  }
  if (!parsed.hostname) {
    return { ok: false, error: '服务器地址缺少主机名' }
  }

  // 剥掉 /api、/api/v1 等路径后缀,只保留站点根(保留显式非标准端口)
  let pathname = parsed.pathname || '/'
  pathname = pathname.replace(/\/+$/, '')
  if (/\/api\/v1$/i.test(pathname)) {
    pathname = pathname.replace(/\/api\/v1$/i, '')
  } else if (/\/api$/i.test(pathname)) {
    pathname = pathname.replace(/\/api$/i, '')
  }
  if (pathname && pathname !== '/' && !/^\/[^/]/.test(pathname)) {
    pathname = ''
  }
  // 允许部署在子路径的站点:仅当 path 不是 api 后缀时保留
  if (/\/api(\/v1)?$/i.test(pathname)) {
    pathname = pathname.replace(/\/api(\/v1)?$/i, '')
  }

  const origin = parsed.origin
  const suffix = pathname === '/' ? '' : pathname
  const baseUrl = stripTrailingSlash(`${origin}${suffix}`)
  return { ok: true, baseUrl }
}

/** 由站点根推导 API 根(无 /v1):{origin}/api */
function deriveApiBase(baseUrl) {
  const normalized = typeof baseUrl === 'string' && baseUrl ? baseUrl : DEFAULT_SERVER_BASE_URL
  return `${stripTrailingSlash(normalized)}/api`
}

/** 由站点根推导默认更新源 */
function deriveUpdateFeedUrl(baseUrl) {
  return `${deriveApiBase(baseUrl)}/uploads/releases/`
}

/** LOCAL runtime 目录命名空间:host 或 host_port */
function serverHostKey(baseUrl) {
  try {
    const url = new URL(String(baseUrl || DEFAULT_SERVER_BASE_URL))
    const host = url.hostname || 'default'
    return url.port ? `${host}_${url.port}` : host
  } catch {
    return 'default'
  }
}

function getConfigVersion() {
  return CONFIG_VERSION
}

function getServerConfigPath(userDataDir) {
  return path.join(String(userDataDir || ''), 'server-config.json')
}

function getServerHistoryPath(userDataDir) {
  return path.join(String(userDataDir || ''), 'server-history.json')
}

const HISTORY_LIMIT = 10

function sanitizeHistoryEntry(entry) {
  if (!entry || typeof entry !== 'object') return null
  const normalized = normalizeServerBaseUrl(entry.serverBaseUrl, { allowHttp: true })
  if (!normalized.ok) return null
  return {
    serverBaseUrl: normalized.baseUrl,
    allowHttp: entry.allowHttp === true,
    lastConnectedAt: typeof entry.lastConnectedAt === 'string' && entry.lastConnectedAt ? entry.lastConnectedAt : null,
  }
}

/** MRU 插入:去重后置顶,超上限截断 */
function mergeServerHistory(list, entry) {
  const clean = sanitizeHistoryEntry(entry)
  const filtered = (Array.isArray(list) ? list : [])
    .map(sanitizeHistoryEntry)
    .filter((e) => e && (!clean || e.serverBaseUrl !== clean.serverBaseUrl))
  if (!clean) return filtered.slice(0, HISTORY_LIMIT)
  return [clean, ...filtered].slice(0, HISTORY_LIMIT)
}

function readServerHistory(userDataDir) {
  try {
    const filePath = getServerHistoryPath(userDataDir)
    if (!fs.existsSync(filePath)) return []
    const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
    const list = Array.isArray(data) ? data : data?.history
    if (!Array.isArray(list)) return []
    return list.map(sanitizeHistoryEntry).filter(Boolean).slice(0, HISTORY_LIMIT)
  } catch {
    return []
  }
}

function writeServerHistory(userDataDir, entries) {
  const filePath = getServerHistoryPath(userDataDir)
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
  fs.writeFileSync(filePath, JSON.stringify(entries, null, 2), 'utf8')
}

function recordServerHistory(userDataDir, serverBaseUrl, allowHttp) {
  const next = mergeServerHistory(readServerHistory(userDataDir), {
    serverBaseUrl,
    allowHttp: allowHttp === true,
    lastConnectedAt: new Date().toISOString(),
  })
  writeServerHistory(userDataDir, next)
  return next
}

function removeServerHistory(userDataDir, serverBaseUrl) {
  const next = readServerHistory(userDataDir).filter((e) => e.serverBaseUrl !== serverBaseUrl)
  writeServerHistory(userDataDir, next)
  return next
}

function defaultServerConfig() {
  return {
    version: CONFIG_VERSION,
    serverBaseUrl: DEFAULT_SERVER_BASE_URL,
    updateFeedMode: 'follow-site',
    allowHttp: false,
    configured: false,
    updatedAt: null,
  }
}

/**
 * @param {string} userDataDir
 * @returns {{ config: ReturnType<typeof defaultServerConfig>, fromFile: boolean }}
 */
function readServerConfig(userDataDir) {
  const defaults = defaultServerConfig()
  const filePath = getServerConfigPath(userDataDir)
  try {
    if (!fs.existsSync(filePath)) return { config: defaults, fromFile: false }
    const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
    const updateFeedMode = UPDATE_MODES.includes(data?.updateFeedMode)
      ? data.updateFeedMode
      : defaults.updateFeedMode
    const normalized = normalizeServerBaseUrl(data?.serverBaseUrl ?? defaults.serverBaseUrl, {
      allowHttp: data?.allowHttp === true,
    })
    return {
      config: {
        version: CONFIG_VERSION,
        serverBaseUrl: normalized.ok ? normalized.baseUrl : defaults.serverBaseUrl,
        updateFeedMode,
        allowHttp: data?.allowHttp === true,
        configured: data?.configured === true,
        updatedAt: data?.updatedAt || null,
      },
      fromFile: true,
    }
  } catch {
    return { config: defaults, fromFile: false }
  }
}

/**
 * @param {string} userDataDir
 * @param {Partial<ReturnType<typeof defaultServerConfig>>} patch
 */
function writeServerConfig(userDataDir, patch) {
  const { config: current } = readServerConfig(userDataDir)
  const allowHttp = patch.allowHttp === true || current.allowHttp === true
  const rawBase = patch.serverBaseUrl != null ? patch.serverBaseUrl : current.serverBaseUrl
  const normalized = normalizeServerBaseUrl(rawBase, { allowHttp: patch.allowHttp === true || allowHttp })
  if (!normalized.ok) return { ok: false, error: normalized.error }

  const updateFeedMode = UPDATE_MODES.includes(patch.updateFeedMode)
    ? patch.updateFeedMode
    : current.updateFeedMode

  const next = {
    version: CONFIG_VERSION,
    serverBaseUrl: normalized.baseUrl,
    updateFeedMode,
    allowHttp: patch.allowHttp === true || (patch.allowHttp == null && current.allowHttp === true),
    configured: patch.configured !== false,
    updatedAt: new Date().toISOString(),
  }

  const filePath = getServerConfigPath(userDataDir)
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
  fs.writeFileSync(filePath, JSON.stringify(next, null, 2), 'utf8')
  return { ok: true, config: next }
}

/**
 * 站点探测:优先 version.json({version,buildTime}),否则看首页是否像 Mao。
 * @param {string} baseUrl
 * @param {{ timeoutMs?: number, fetchImpl?: typeof fetch }} [options]
 */
async function probeServer(baseUrl, options = {}) {
  const timeoutMs = options.timeoutMs ?? 5000
  const fetchImpl = options.fetchImpl || (typeof fetch === 'function' ? fetch : null)
  const normalized = normalizeServerBaseUrl(baseUrl, { allowHttp: true })
  if (!normalized.ok) return { ok: false, error: normalized.error }
  const origin = new URL(normalized.baseUrl).origin
  if (!fetchImpl) return { ok: false, error: '当前环境不支持网络探测' }

  const probes = [
    { url: `${origin}/version.json`, kind: 'version' },
    { url: `${origin}/`, kind: 'root' },
  ]

  let lastError = '无法连接该服务器'
  for (const probe of probes) {
    const controller = new AbortController()
    const timer = setTimeout(() => controller.abort(), timeoutMs)
    try {
      const resp = await fetchImpl(probe.url, {
        method: 'GET',
        signal: controller.signal,
        redirect: 'follow',
        headers: { Accept: 'application/json,text/html,*/*' },
      })
      if (probe.kind === 'version') {
        if (resp.ok) {
          const text = await resp.text()
          try {
            const data = JSON.parse(text)
            if (data && typeof data.version === 'string') {
              return { ok: true, baseUrl: normalized.baseUrl, detail: `version ${data.version}` }
            }
          } catch {
            // fall through
          }
          lastError = 'version.json 响应异常'
        } else {
          lastError = `version.json HTTP ${resp.status}`
        }
      } else {
        if (resp.ok) {
          const text = await resp.text()
          const looksLikeMao =
            /Mao/i.test(text) ||
            /\/assets\//.test(text) ||
            /app-icon/i.test(text)
          if (looksLikeMao) {
            return { ok: true, baseUrl: normalized.baseUrl, detail: '站点可达' }
          }
          return {
            ok: false,
            error: '站点可达,但可能不是 Mao 部署',
            baseUrl: normalized.baseUrl,
          }
        }
        lastError = `站点 HTTP ${resp.status}`
      }
    } catch (e) {
      lastError = e?.name === 'AbortError' ? '连接超时' : (e?.message || '网络错误')
    } finally {
      clearTimeout(timer)
    }
  }

  return { ok: false, error: lastError, baseUrl: normalized.baseUrl }
}

function resolveUpdateFeedUrl(config, envOverride) {
  if (envOverride && String(envOverride).trim()) {
    return { mode: 'env-override', url: String(envOverride).trim() }
  }
  const mode = UPDATE_MODES.includes(config?.updateFeedMode) ? config.updateFeedMode : 'follow-site'
  if (mode === 'disabled' || mode === 'package-default') {
    return { mode, url: null }
  }
  return { mode: 'follow-site', url: deriveUpdateFeedUrl(config?.serverBaseUrl || DEFAULT_SERVER_BASE_URL) }
}

module.exports = {
  DEFAULT_SERVER_BASE_URL,
  UPDATE_MODES,
  normalizeServerBaseUrl,
  deriveApiBase,
  deriveUpdateFeedUrl,
  serverHostKey,
  getServerConfigPath,
  getServerHistoryPath,
  HISTORY_LIMIT,
  readServerHistory,
  writeServerHistory,
  recordServerHistory,
  removeServerHistory,
  mergeServerHistory,
  sanitizeHistoryEntry,
  defaultServerConfig,
  readServerConfig,
  writeServerConfig,
  probeServer,
  resolveUpdateFeedUrl,
  getConfigVersion,
}