Ctrl K
ComunidadGuías…

desktop/electron/chatAttachment.cjs

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

Archivo original ↗Preguntar a KiBot
'use strict'

/**
 * LOCAL 附件拷贝(P1):发送时把用户拖入/选中的任意文件复制到
 * `~/.mao/attachments/<serverHost>/<sessionId>/`(机器级、按 host 隔离,
 * 仿 ~/.mao/runtime/<host>/<sid>/ 既有约定),返回绝对路径引用 @{...}@。
 *
 * 纯逻辑 + 可注入 fs,node --test 可单测;IPC handler 由 main.cjs 注册。
 * 目标目录是 Mao 自管目录(不涉及工作区),收敛由「basename sanitize +
 * hostKey/sessionId 强校验 + 固定前缀 + home 相对断言」按构造保证。
 */

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

const ATTACHMENTS_DIR_NAME = '.mao'
const ATTACHMENTS_SUBDIR = 'attachments'
const MAX_NAME_LENGTH = 100
const FALLBACK_NAME = 'attachment'
const DEFAULT_MAX_SIZE_MB = 100
/**
 * 主进程硬封顶:渲染层传入的 maxSizeMb 来自后端上传配置(默认 1024),
 * 不封顶时一个接近 1GB 的附件会把主进程同步阻塞数十秒(应用「未响应」)。
 * 异步拷贝解决阻塞后,此上限仅防病态配置(如 TB 级)放行超大文件。
 */
const MAIN_PROCESS_MAX_SIZE_MB = 512
const UNIQUE_NAME_MAX_ATTEMPTS = 10000

/** Windows 保留设备名(不分大小写;CON.txt 这类带扩展名的同样保留) */
const WINDOWS_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i
/** 控制字符(含 \t \n \r)一律剥除,避免文件名里藏不可见字符 */
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g

function truncateName(name) {
  if (name.length <= MAX_NAME_LENGTH) return name
  const ext = path.extname(name)
  if (ext.length >= MAX_NAME_LENGTH) return name.slice(0, MAX_NAME_LENGTH)
  const stem = name.slice(0, name.length - ext.length)
  return stem.slice(0, MAX_NAME_LENGTH - ext.length) + ext
}

/**
 * 文件名 sanitize:basename 化、剥控制字符与首尾点/空格、Windows 保留名加
 * `_` 前缀、超长截断(保留扩展名,总长 ≤100)。幂等。
 * @param {string} raw
 * @returns {string}
 */
function sanitizeAttachmentName(raw) {
  let name = String(raw ?? '')
  // basename 化:只取最后一段(POSIX / 与 Windows \ 都算分隔符)
  const segments = name.split(/[\\/]/)
  name = segments[segments.length - 1] ?? ''
  name = name.replace(CONTROL_CHARS, '')
  name = name.replace(/^\.+/, '').replace(/[. ]+$/, '')
  if (!name) return FALLBACK_NAME
  const stem = name.slice(0, name.length - path.extname(name).length)
  if (WINDOWS_RESERVED.test(stem)) {
    name = `_${name}`
  }
  name = truncateName(name)
  // 截断可能重新露出尾点/尾空格,再收一次;仍为空则回落
  name = name.replace(/[. ]+$/, '')
  if (!name) return FALLBACK_NAME
  return name
}

/**
 * hostKey 强校验:非空、无路径分隔符、不是 . / ..、无控制字符。
 * 防 `../1` 之类把附件写到别的 host 目录甚至越出 attachments 根。
 * @param {string} hostKey
 * @returns {boolean}
 */
function isValidHostKey(hostKey) {
  if (typeof hostKey !== 'string') return false
  if (!hostKey || hostKey !== hostKey.trim()) return false
  if (/[\\/]/.test(hostKey)) return false
  if (hostKey === '.' || hostKey === '..') return false
  // eslint-disable-next-line no-control-regex
  if (/[\u0000-\u001f\u007f]/.test(hostKey)) return false
  return true
}

/**
 * sessionId 强校验:正整数(接受数字字符串,拒绝 `1/../../x` 等注入)。
 * @param {number|string} sessionId
 * @returns {boolean}
 */
function isValidSessionId(sessionId) {
  if (typeof sessionId === 'number') return Number.isSafeInteger(sessionId) && sessionId > 0
  if (typeof sessionId !== 'string') return false
  if (!/^\d+$/.test(sessionId.trim())) return false
  const parsed = Number(sessionId.trim())
  return Number.isSafeInteger(parsed) && parsed > 0
}

/**
 * 解析附件目标路径:`<home>/.mao/attachments/<hostKey>/<sessionId>/<name>`,
 * 并对 home 做 relative 断言不出界(home 自身是符号链接时先 realpath)。
 *
 * @param {string} homeDir 用户主目录(os.homedir())
 * @param {string} hostKey 服务器 host 命名空间(主进程 getServerHostKey())
 * @param {number|string} sessionId 会话 ID
 * @param {string} name 原始文件名(内部会 sanitize)
 * @param {{ fsImpl?: typeof fs }} [deps]
 * @returns {string} 绝对路径(已 normalize,未展开 ~)
 */
function resolveAttachmentTarget(homeDir, hostKey, sessionId, name, deps = {}) {
  const fsImpl = deps.fsImpl || fs
  if (!isValidHostKey(hostKey)) {
    throw new Error('服务器标识无效,无法确定附件目录')
  }
  if (!isValidSessionId(sessionId)) {
    throw new Error('会话无效')
  }
  const safeName = sanitizeAttachmentName(name)
  if (!safeName) {
    throw new Error('文件名无效')
  }

  const home = path.resolve(String(homeDir || ''))
  let realHome = home
  try {
    realHome = fsImpl.realpathSync(home)
  } catch {
    // home 不存在等异常交由后续 existsSync/写入路径报出可读错误
  }
  const maoDir = path.join(realHome, ATTACHMENTS_DIR_NAME)
  if (fsImpl.existsSync(maoDir) && !fsImpl.statSync(maoDir).isDirectory()) {
    throw new Error(`~/${ATTACHMENTS_DIR_NAME} 已存在且不是目录,请手动检查后重试`)
  }

  const target = path.resolve(
    realHome,
    ATTACHMENTS_DIR_NAME,
    ATTACHMENTS_SUBDIR,
    hostKey,
    String(sessionId).trim(),
    safeName
  )
  const rel = path.relative(realHome, target)
  if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
    throw new Error('附件目录必须位于 ~/.mao/attachments 下')
  }
  return target
}

/**
 * 目录内重名规避:`stem-1.ext`、`stem-2.ext`… 确定性递增。
 * @param {string} dir
 * @param {string} name
 * @param {{ fsImpl?: typeof fs }} [deps]
 * @returns {string}
 */
function uniqueName(dir, name, deps = {}) {
  const fsImpl = deps.fsImpl || fs
  if (!fsImpl.existsSync(path.join(dir, name))) return name
  const ext = path.extname(name)
  const stem = name.slice(0, name.length - ext.length)
  for (let i = 1; i <= UNIQUE_NAME_MAX_ATTEMPTS; i++) {
    const candidate = `${stem}-${i}${ext}`
    if (!fsImpl.existsSync(path.join(dir, candidate))) return candidate
  }
  throw new Error('同名文件过多,请重命名后重试')
}

/**
 * 有效大小上限:主进程对渲染层传入值做硬封顶。
 * @param {number} [maxSizeMb]
 * @returns {number}
 */
function effectiveMaxSizeMb(maxSizeMb) {
  const base = Number.isFinite(maxSizeMb) && maxSizeMb > 0
    ? Number(maxSizeMb)
    : DEFAULT_MAX_SIZE_MB
  return Math.min(base, MAIN_PROCESS_MAX_SIZE_MB)
}

/**
 * 校验 + 落点解析(同步、廉价):lstat 复核类型与大小、解析目标路径、重名规避。
 * 拷文件本身(唯一的重活)由调用方选择同步/异步执行。
 *
 * @param {{
 *   sourcePath: string,
 *   sessionId: number|string,
 *   maxSizeMb?: number,
 *   homeDir?: string,
 *   hostKey: string,
 *   fsImpl?: typeof fs,
 * }} params
 * @returns {{ ok: true, sourcePath: string, dir: string, finalPath: string } | { ok: false, error: string }}
 */
function prepareLocalAttachmentCopy(params) {
  const fsImpl = params.fsImpl || fs
  const sourcePath = typeof params.sourcePath === 'string' ? params.sourcePath : ''
  const maxSizeMb = effectiveMaxSizeMb(params.maxSizeMb)

  try {
    if (!sourcePath) return { ok: false, error: '文件路径无效' }
    if (!isValidSessionId(params.sessionId)) return { ok: false, error: '会话无效' }

    let stat
    try {
      // lstat 不跟随符号链接:symlink → 文件也会被 isSymbolicLink 抓到并拒绝
      stat = fsImpl.lstatSync(sourcePath)
    } catch (e) {
      if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) {
        return { ok: false, error: '文件已不存在,可能已被移动或删除' }
      }
      if (e && e.code === 'EACCES') {
        return { ok: false, error: '没有读取该文件的权限' }
      }
      return { ok: false, error: `无法读取文件:${e?.message || '未知错误'}` }
    }
    if (stat.isDirectory()) return { ok: false, error: '请选择文件,暂不支持发送文件夹' }
    if (stat.isSymbolicLink()) return { ok: false, error: '不支持符号链接,请选择实际文件' }
    if (!stat.isFile()) return { ok: false, error: '请选择普通文件' }
    if (stat.size === 0) return { ok: false, error: '文件为空,无法发送' }
    if (stat.size > maxSizeMb * 1024 * 1024) {
      return { ok: false, error: `文件超过 ${maxSizeMb}MB 限制` }
    }

    const target = resolveAttachmentTarget(params.homeDir, params.hostKey, params.sessionId, path.basename(sourcePath), { fsImpl })
    const dir = path.dirname(target)
    const finalName = uniqueName(dir, path.basename(target), { fsImpl })
    const finalPath = path.join(dir, finalName)
    return { ok: true, sourcePath, dir, finalPath }
  } catch (e) {
    return { ok: false, error: e?.message || '拷贝失败' }
  }
}

/**
 * 拷贝单个附件到会话目录(LOCAL 懒拷贝的落点)。同步版,保留供测试与兼容调用。
 * 防御纵深:主进程侧重新 lstat(不跟随符号链接)复核类型与大小。
 *
 * @param {{
 *   sourcePath: string,
 *   sessionId: number|string,
 *   maxSizeMb?: number,
 *   homeDir?: string,
 *   hostKey: string,
 *   fsImpl?: typeof fs,
 * }} params
 * @returns {{ ok: true, absolutePath: string } | { ok: false, error: string }}
 */
function copyLocalAttachment(params) {
  const fsImpl = params.fsImpl || fs
  const prepared = prepareLocalAttachmentCopy(params)
  if (!prepared.ok) return prepared
  try {
    fsImpl.mkdirSync(prepared.dir, { recursive: true })
    fsImpl.copyFileSync(prepared.sourcePath, prepared.finalPath)
    return { ok: true, absolutePath: prepared.finalPath }
  } catch (e) {
    return { ok: false, error: e?.message || '拷贝失败' }
  }
}

/**
 * 异步版拷贝(IPC 默认路径):大文件的 copyFile 不再阻塞主进程事件循环,
 * 避免拖入大附件时整个应用卡死/「未响应」。校验逻辑与同步版完全一致。
 *
 * @param {{
 *   sourcePath: string,
 *   sessionId: number|string,
 *   maxSizeMb?: number,
 *   homeDir?: string,
 *   hostKey: string,
 *   fsImpl?: typeof fs,
 * }} params
 * @returns {Promise<{ ok: true, absolutePath: string } | { ok: false, error: string }>}
 */
async function copyLocalAttachmentAsync(params) {
  const fsImpl = params.fsImpl || fs
  const prepared = prepareLocalAttachmentCopy(params)
  if (!prepared.ok) return prepared
  try {
    fsImpl.mkdirSync(prepared.dir, { recursive: true })
    const promises = fsImpl.promises || fs.promises
    await promises.copyFile(prepared.sourcePath, prepared.finalPath)
    return { ok: true, absolutePath: prepared.finalPath }
  } catch (e) {
    return { ok: false, error: e?.message || '拷贝失败' }
  }
}

/**
 * 文本落盘(dump_user_messages / 粘贴附件转文件):把文本写到
 * `~/.mao/attachments/<hostKey>/<sessionId>/<fileName>`,返回本机绝对路径。
 *
 * 同名处理由 `dedupe` 决定(缺省 true,与附件拷贝同语义:追加 `-1`、`-2` 副本):
 * 渲染层粘贴转出的 `pasted-text-N.txt` 必须避重,否则同会话第二段长文本会覆盖第一段,
 * 让历史消息里的 `@{...pasted-text-1.txt}@` 静默指向新内容;`dump_user_messages`
 * 的文件名由消息 id 决定,显式传 `dedupe:false` 保持按 id 幂等覆盖。
 * 落点仍走 resolveAttachmentTarget 的 sanitize + host/session 强校验 + home 相对断言。
 *
 * @param {{
 *   fileName: string,
 *   content: string,
 *   sessionId: number|string,
 *   maxSizeMb?: number,
 *   dedupe?: boolean,
 *   homeDir?: string,
 *   hostKey: string,
 *   fsImpl?: typeof fs,
 * }} params
 * @returns {Promise<{ ok: true, absolutePath: string, bytes: number } | { ok: false, error: string }>}
 */
async function writeLocalTextFileAsync(params) {
  const fsImpl = params.fsImpl || fs
  try {
    if (typeof params.fileName !== 'string' || params.fileName.trim() === '') {
      return { ok: false, error: '文件名无效' }
    }
    if (typeof params.content !== 'string') return { ok: false, error: '文本内容无效' }
    const maxSizeMb = effectiveMaxSizeMb(params.maxSizeMb)
    const bytes = Buffer.byteLength(params.content, 'utf8')
    if (bytes > maxSizeMb * 1024 * 1024) {
      return { ok: false, error: `文本超过 ${maxSizeMb}MB 限制` }
    }
    const dedupe = params.dedupe !== false
    const target = resolveAttachmentTarget(params.homeDir, params.hostKey, params.sessionId, params.fileName, { fsImpl })
    const dir = path.dirname(target)
    fsImpl.mkdirSync(dir, { recursive: true })
    const finalName = dedupe ? uniqueName(dir, path.basename(target), { fsImpl }) : path.basename(target)
    const finalPath = path.join(dir, finalName)
    const promises = fsImpl.promises || fs.promises
    await promises.writeFile(finalPath, params.content, 'utf8')
    return { ok: true, absolutePath: finalPath, bytes }
  } catch (e) {
    return { ok: false, error: e?.message || '写入失败' }
  }
}

module.exports = {
  ATTACHMENTS_DIR_NAME,
  ATTACHMENTS_SUBDIR,
  MAX_NAME_LENGTH,
  DEFAULT_MAX_SIZE_MB,
  MAIN_PROCESS_MAX_SIZE_MB,
  sanitizeAttachmentName,
  isValidHostKey,
  isValidSessionId,
  resolveAttachmentTarget,
  uniqueName,
  prepareLocalAttachmentCopy,
  copyLocalAttachment,
  copyLocalAttachmentAsync,
  writeLocalTextFileAsync,
}