package app import ( "archive/zip" "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "git.echol.cn/loser/st/server/global" "git.echol.cn/loser/st/server/model/app" "go.uber.org/zap" "gorm.io/datatypes" ) // extensionsBaseDir 扩展文件存放根目录(与 router.go 中的静态服务路径一致) const extensionsBaseDir = "data/st-core-scripts/scripts/extensions/third-party" // STManifest SillyTavern 扩展 manifest.json 结构 type STManifest struct { DisplayName string `json:"display_name"` Loading string `json:"loading_order"` // 加载顺序 Requires []string `json:"requires"` Optional []string `json:"optional"` Js string `json:"js"` // 入口 JS 文件 Css string `json:"css"` // 入口 CSS 文件 Author string `json:"author"` Version string `json:"version"` Homepages string `json:"homepages"` Repository string `json:"repository"` AutoUpdate bool `json:"auto_update"` Description string `json:"description"` Tags []string `json:"tags"` Settings map[string]interface{} `json:"settings"` Raw map[string]interface{} `json:"-"` // 原始 JSON 数据 } // getExtensionDir 获取指定扩展的文件系统目录 func getExtensionDir(extName string) string { return filepath.Join(extensionsBaseDir, extName) } // ensureExtensionsBaseDir 确保扩展基础目录存在 func ensureExtensionsBaseDir() error { return os.MkdirAll(extensionsBaseDir, 0755) } // parseManifestFile 从扩展目录中读取并解析 manifest.json func parseManifestFile(dir string) (*STManifest, error) { manifestPath := filepath.Join(dir, "manifest.json") data, err := os.ReadFile(manifestPath) if err != nil { return nil, fmt.Errorf("无法读取 manifest.json: %w", err) } var manifest STManifest if err := json.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("解析 manifest.json 失败: %w", err) } // 保留原始 JSON 用于存储到数据库 var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err == nil { manifest.Raw = raw } return &manifest, nil } // parseManifestBytes 从字节数组解析 manifest.json func parseManifestBytes(data []byte) (*STManifest, error) { var manifest STManifest if err := json.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("解析 manifest.json 失败: %w", err) } var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err == nil { manifest.Raw = raw } return &manifest, nil } // InstallExtensionFromGit 从 Git 仓库安装扩展 func (s *ExtensionService) InstallExtensionFromGit(userID uint, gitURL string, branch string) (*app.AIExtension, error) { if branch == "" { branch = "main" } if err := ensureExtensionsBaseDir(); err != nil { return nil, fmt.Errorf("创建扩展目录失败: %w", err) } // 从 URL 提取扩展名 extName := extractRepoName(gitURL) if extName == "" { return nil, errors.New("无法从 URL 中提取扩展名") } extDir := getExtensionDir(extName) // 检查目录是否已存在 if _, err := os.Stat(extDir); err == nil { return nil, fmt.Errorf("扩展 '%s' 已存在,请先删除或使用升级功能", extName) } global.GVA_LOG.Info("从 Git 安装扩展", zap.String("url", gitURL), zap.String("branch", branch), zap.String("dir", extDir), ) // 执行 git clone cmd := exec.Command("git", "clone", "--depth", "1", "--branch", branch, gitURL, extDir) output, err := cmd.CombinedOutput() if err != nil { global.GVA_LOG.Error("git clone 失败", zap.String("output", string(output)), zap.Error(err), ) // 清理失败的目录 _ = os.RemoveAll(extDir) return nil, fmt.Errorf("git clone 失败: %s", strings.TrimSpace(string(output))) } global.GVA_LOG.Info("git clone 成功", zap.String("name", extName)) // 如果扩展需要构建(有 package.json 的 build 脚本且 dist 不存在),执行构建 if err := buildExtensionIfNeeded(extDir); err != nil { global.GVA_LOG.Warn("扩展构建失败(不影响安装)", zap.String("name", extName), zap.Error(err), ) } // 创建数据库记录 return s.createExtensionFromDir(userID, extDir, extName, "git", gitURL, branch) } // InstallExtensionFromManifestURL 从 manifest URL 安装扩展 func (s *ExtensionService) InstallExtensionFromManifestURL(userID uint, manifestURL string, branch string) (*app.AIExtension, error) { if err := ensureExtensionsBaseDir(); err != nil { return nil, fmt.Errorf("创建扩展目录失败: %w", err) } global.GVA_LOG.Info("从 Manifest URL 安装扩展", zap.String("url", manifestURL)) // 下载 manifest.json manifestData, err := httpGet(manifestURL) if err != nil { return nil, fmt.Errorf("下载 manifest.json 失败: %w", err) } manifest, err := parseManifestBytes(manifestData) if err != nil { return nil, err } // 确定扩展名 extName := sanitizeName(manifest.DisplayName) if extName == "" { extName = extractNameFromURL(manifestURL) } if extName == "" { return nil, errors.New("无法确定扩展名,manifest 中缺少 display_name") } extDir := getExtensionDir(extName) if _, err := os.Stat(extDir); err == nil { return nil, fmt.Errorf("扩展 '%s' 已存在,请先删除或使用升级功能", extName) } if err := os.MkdirAll(extDir, 0755); err != nil { return nil, fmt.Errorf("创建扩展目录失败: %w", err) } // 保存 manifest.json if err := os.WriteFile(filepath.Join(extDir, "manifest.json"), manifestData, 0644); err != nil { _ = os.RemoveAll(extDir) return nil, fmt.Errorf("保存 manifest.json 失败: %w", err) } // 获取 manifest URL 的基础路径 baseURL := manifestURL[:strings.LastIndex(manifestURL, "/")+1] // 下载 JS 入口文件 if manifest.Js != "" { jsURL := baseURL + manifest.Js jsData, err := httpGet(jsURL) if err != nil { global.GVA_LOG.Warn("下载 JS 文件失败", zap.String("url", jsURL), zap.Error(err)) } else { if err := os.WriteFile(filepath.Join(extDir, manifest.Js), jsData, 0644); err != nil { global.GVA_LOG.Warn("保存 JS 文件失败", zap.Error(err)) } } } // 下载 CSS 文件 if manifest.Css != "" { cssURL := baseURL + manifest.Css cssData, err := httpGet(cssURL) if err != nil { global.GVA_LOG.Warn("下载 CSS 文件失败", zap.String("url", cssURL), zap.Error(err)) } else { if err := os.WriteFile(filepath.Join(extDir, manifest.Css), cssData, 0644); err != nil { global.GVA_LOG.Warn("保存 CSS 文件失败", zap.Error(err)) } } } // 创建数据库记录 return s.createExtensionFromDir(userID, extDir, extName, "url", manifestURL, branch) } // ImportExtensionFromZip 从 zip 文件导入扩展 func (s *ExtensionService) ImportExtensionFromZip(userID uint, filename string, zipData []byte) (*app.AIExtension, error) { if err := ensureExtensionsBaseDir(); err != nil { return nil, fmt.Errorf("创建扩展目录失败: %w", err) } // 先解压到临时目录 tmpDir, err := os.MkdirTemp("", "ext-import-*") if err != nil { return nil, fmt.Errorf("创建临时目录失败: %w", err) } defer os.RemoveAll(tmpDir) // 解压 zip if err := extractZip(zipData, tmpDir); err != nil { return nil, fmt.Errorf("解压 zip 失败: %w", err) } // 找到 manifest.json 所在目录(可能在根目录或子目录) manifestDir, err := findManifestDir(tmpDir) if err != nil { return nil, err } // 解析 manifest manifest, err := parseManifestFile(manifestDir) if err != nil { return nil, err } // 确定扩展名 extName := sanitizeName(manifest.DisplayName) if extName == "" { extName = strings.TrimSuffix(filename, filepath.Ext(filename)) } extDir := getExtensionDir(extName) if _, err := os.Stat(extDir); err == nil { return nil, fmt.Errorf("扩展 '%s' 已存在,请先删除或使用升级功能", extName) } // 移动文件到目标目录 if err := os.Rename(manifestDir, extDir); err != nil { // 如果跨分区移动失败,回退为复制 if err := copyDir(manifestDir, extDir); err != nil { return nil, fmt.Errorf("移动扩展文件失败: %w", err) } } global.GVA_LOG.Info("ZIP 扩展导入成功", zap.String("name", extName), zap.String("dir", extDir), ) return s.createExtensionFromDir(userID, extDir, extName, "file", "", "") } // UpgradeExtensionFromSource 从源地址升级扩展 func (s *ExtensionService) UpgradeExtensionFromSource(userID, extID uint) (*app.AIExtension, error) { ext, err := s.GetExtension(userID, extID) if err != nil { return nil, err } if ext.SourceURL == "" { return nil, errors.New("该扩展没有源地址,无法升级") } extDir := getExtensionDir(ext.Name) switch ext.InstallSource { case "git": // Git 扩展:执行 git pull global.GVA_LOG.Info("从 Git 升级扩展", zap.String("name", ext.Name), zap.String("dir", extDir), ) cmd := exec.Command("git", "-C", extDir, "pull", "--ff-only") output, err := cmd.CombinedOutput() if err != nil { global.GVA_LOG.Error("git pull 失败", zap.String("output", string(output)), zap.Error(err), ) return nil, fmt.Errorf("git pull 失败: %s", strings.TrimSpace(string(output))) } global.GVA_LOG.Info("git pull 成功", zap.String("output", string(output))) // 如果扩展需要构建,执行构建 if err := buildExtensionIfNeeded(extDir); err != nil { global.GVA_LOG.Warn("升级后扩展构建失败", zap.String("name", ext.Name), zap.Error(err), ) } case "url": // URL 扩展:重新下载 manifest 和文件 manifestData, err := httpGet(ext.SourceURL) if err != nil { return nil, fmt.Errorf("下载 manifest.json 失败: %w", err) } manifest, err := parseManifestBytes(manifestData) if err != nil { return nil, err } // 覆盖写入 manifest.json if err := os.WriteFile(filepath.Join(extDir, "manifest.json"), manifestData, 0644); err != nil { return nil, fmt.Errorf("保存 manifest.json 失败: %w", err) } baseURL := ext.SourceURL[:strings.LastIndex(ext.SourceURL, "/")+1] // 重新下载 JS if manifest.Js != "" { if jsData, err := httpGet(baseURL + manifest.Js); err == nil { _ = os.WriteFile(filepath.Join(extDir, manifest.Js), jsData, 0644) } } // 重新下载 CSS if manifest.Css != "" { if cssData, err := httpGet(baseURL + manifest.Css); err == nil { _ = os.WriteFile(filepath.Join(extDir, manifest.Css), cssData, 0644) } } default: return nil, errors.New("该扩展的安装来源不支持升级") } // 重新解析 manifest 并更新数据库 manifest, _ := parseManifestFile(extDir) if manifest != nil { now := time.Now().Unix() updates := map[string]interface{}{ "last_update_check": &now, } if manifest.Version != "" { updates["version"] = manifest.Version } if manifest.Description != "" { updates["description"] = manifest.Description } if manifest.Author != "" { updates["author"] = manifest.Author } if manifest.Js != "" { updates["script_path"] = manifest.Js } if manifest.Css != "" { updates["style_path"] = manifest.Css } if manifest.Raw != nil { if raw, err := json.Marshal(manifest.Raw); err == nil { updates["manifest_data"] = datatypes.JSON(raw) } } global.GVA_DB.Model(&app.AIExtension{}).Where("id = ? AND user_id = ?", extID, userID).Updates(updates) } return s.GetExtension(userID, extID) } // InstallExtensionFromURL 智能安装:根据 URL 判断是 Git 仓库还是 Manifest URL func (s *ExtensionService) InstallExtensionFromURL(userID uint, url string, branch string) (*app.AIExtension, error) { if isGitURL(url) { return s.InstallExtensionFromGit(userID, url, branch) } return s.InstallExtensionFromManifestURL(userID, url, branch) } // --------------------- // 辅助函数 // --------------------- // createExtensionFromDir 从扩展目录创建数据库记录 func (s *ExtensionService) createExtensionFromDir(userID uint, extDir, extName, installSource, sourceURL, branch string) (*app.AIExtension, error) { manifest, err := parseManifestFile(extDir) if err != nil { // manifest 解析失败不阻止安装,使用基本信息 global.GVA_LOG.Warn("解析 manifest 失败,使用基本信息", zap.Error(err)) manifest = &STManifest{} } now := time.Now().Unix() displayName := manifest.DisplayName if displayName == "" { displayName = extName } version := manifest.Version if version == "" { version = "1.0.0" } ext := app.AIExtension{ UserID: userID, Name: extName, DisplayName: displayName, Version: version, Author: manifest.Author, Description: manifest.Description, Homepage: manifest.Homepages, Repository: manifest.Repository, ExtensionType: "ui", ScriptPath: manifest.Js, StylePath: manifest.Css, InstallSource: installSource, SourceURL: sourceURL, Branch: branch, IsInstalled: true, IsEnabled: false, InstallDate: &now, AutoUpdate: manifest.AutoUpdate, } // 存储 manifest 原始数据 if manifest.Raw != nil { if raw, err := json.Marshal(manifest.Raw); err == nil { ext.ManifestData = datatypes.JSON(raw) } } // 存储标签 if len(manifest.Tags) > 0 { if tags, err := json.Marshal(manifest.Tags); err == nil { ext.Tags = datatypes.JSON(tags) } } // 存储默认设置 if manifest.Settings != nil { if settings, err := json.Marshal(manifest.Settings); err == nil { ext.Settings = datatypes.JSON(settings) } } // 存储依赖 if len(manifest.Requires) > 0 { deps := make(map[string]string) for _, r := range manifest.Requires { deps[r] = "*" } if depsJSON, err := json.Marshal(deps); err == nil { ext.Dependencies = datatypes.JSON(depsJSON) } } if err := global.GVA_DB.Create(&ext).Error; err != nil { return nil, fmt.Errorf("创建扩展记录失败: %w", err) } global.GVA_LOG.Info("扩展安装成功", zap.String("name", extName), zap.String("source", installSource), zap.String("version", version), ) return &ext, nil } // buildExtensionIfNeeded 如果扩展目录中有 package.json 且包含 build 脚本, // 而 manifest 中指定的入口 JS 文件不存在,则自动执行 npm/pnpm install && build func buildExtensionIfNeeded(extDir string) error { // 读取 manifest 获取入口文件路径 manifest, err := parseManifestFile(extDir) if err != nil || manifest.Js == "" { return nil // 无 manifest 或无 JS 入口,不需要构建 } // 检查入口 JS 文件是否存在 jsPath := filepath.Join(extDir, manifest.Js) if _, err := os.Stat(jsPath); err == nil { return nil // 入口文件已存在,无需构建 } // 检查 package.json 是否存在 pkgPath := filepath.Join(extDir, "package.json") pkgData, err := os.ReadFile(pkgPath) if err != nil { return nil // 无 package.json,不是需要构建的扩展 } // 检查是否有 build 脚本 var pkg struct { Scripts map[string]string `json:"scripts"` } if err := json.Unmarshal(pkgData, &pkg); err != nil { return nil } if _, hasBuild := pkg.Scripts["build"]; !hasBuild { return nil // 没有 build 脚本 } global.GVA_LOG.Info("扩展需要构建,开始安装依赖及构建", zap.String("dir", extDir), zap.String("entry", manifest.Js), ) // 判断使用 pnpm 还是 npm var pkgManager string if _, err := os.Stat(filepath.Join(extDir, "pnpm-lock.yaml")); err == nil { pkgManager = "pnpm" } else if _, err := os.Stat(filepath.Join(extDir, "pnpm-workspace.yaml")); err == nil { pkgManager = "pnpm" } else { pkgManager = "npm" } // 确认包管理器可用 if _, err := exec.LookPath(pkgManager); err != nil { // 回退到 npm pkgManager = "npm" if _, err := exec.LookPath("npm"); err != nil { return fmt.Errorf("未找到 npm 或 pnpm,无法构建扩展") } } global.GVA_LOG.Info("使用包管理器", zap.String("manager", pkgManager)) // 第一步:安装依赖 installCmd := exec.Command(pkgManager, "install") installCmd.Dir = extDir installOutput, err := installCmd.CombinedOutput() if err != nil { global.GVA_LOG.Error("依赖安装失败", zap.String("output", string(installOutput)), zap.Error(err), ) return fmt.Errorf("%s install 失败: %s", pkgManager, strings.TrimSpace(string(installOutput))) } global.GVA_LOG.Info("依赖安装成功", zap.String("manager", pkgManager)) // 第二步:执行构建 buildCmd := exec.Command(pkgManager, "run", "build") buildCmd.Dir = extDir buildOutput, err := buildCmd.CombinedOutput() if err != nil { global.GVA_LOG.Error("构建失败", zap.String("output", string(buildOutput)), zap.Error(err), ) return fmt.Errorf("%s run build 失败: %s", pkgManager, strings.TrimSpace(string(buildOutput))) } global.GVA_LOG.Info("扩展构建成功", zap.String("dir", extDir)) // 验证入口文件是否已生成 if _, err := os.Stat(jsPath); err != nil { return fmt.Errorf("构建完成但入口文件仍不存在: %s", manifest.Js) } return nil } // isGitURL 判断 URL 是否为 Git 仓库 func isGitURL(url string) bool { url = strings.ToLower(url) if strings.HasSuffix(url, ".git") { return true } if strings.Contains(url, "github.com/") || strings.Contains(url, "gitlab.com/") || strings.Contains(url, "gitee.com/") || strings.Contains(url, "bitbucket.org/") { // 排除以 .json 结尾的 URL if strings.HasSuffix(url, ".json") { return false } return true } return false } // extractRepoName 从 Git URL 提取仓库名 func extractRepoName(gitURL string) string { gitURL = strings.TrimSuffix(gitURL, ".git") gitURL = strings.TrimRight(gitURL, "/") parts := strings.Split(gitURL, "/") if len(parts) == 0 { return "" } return parts[len(parts)-1] } // extractNameFromURL 从 URL 路径中提取名称 func extractNameFromURL(url string) string { // 对于 manifest URL:https://example.com/extensions/my-ext/manifest.json // 提取上一级目录名 url = strings.TrimRight(url, "/") parts := strings.Split(url, "/") if len(parts) >= 2 { filename := parts[len(parts)-1] if strings.Contains(filename, "manifest") { return parts[len(parts)-2] } } return "" } // sanitizeName 清理扩展名(移除不安全字符) func sanitizeName(name string) string { name = strings.TrimSpace(name) // 将空格替换为连字符 name = strings.ReplaceAll(name, " ", "-") // 只保留字母、数字、连字符、下划线 var result strings.Builder for _, c := range name { if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { result.WriteRune(c) } } return result.String() } // httpGet 发送 HTTP GET 请求 func httpGet(url string) ([]byte, error) { client := &http.Client{ Timeout: 60 * time.Second, } resp, err := client.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) } return io.ReadAll(resp.Body) } // extractZip 解压 zip 文件到指定目录 func extractZip(zipData []byte, destDir string) error { reader, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) if err != nil { return fmt.Errorf("打开 zip 文件失败: %w", err) } for _, file := range reader.File { // 安全检查:防止 zip slip 攻击 destPath := filepath.Join(destDir, file.Name) if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)+string(os.PathSeparator)) { return fmt.Errorf("非法的 zip 文件路径: %s", file.Name) } if file.FileInfo().IsDir() { if err := os.MkdirAll(destPath, 0755); err != nil { return err } continue } // 确保父目录存在 if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { return err } rc, err := file.Open() if err != nil { return err } outFile, err := os.Create(destPath) if err != nil { rc.Close() return err } _, err = io.Copy(outFile, rc) outFile.Close() rc.Close() if err != nil { return err } } return nil } // findManifestDir 在解压的目录中查找 manifest.json 所在目录 func findManifestDir(rootDir string) (string, error) { // 先检查根目录 if _, err := os.Stat(filepath.Join(rootDir, "manifest.json")); err == nil { return rootDir, nil } // 检查一级子目录(常见的 zip 结构是 zip 内包含一个项目文件夹) entries, err := os.ReadDir(rootDir) if err != nil { return "", fmt.Errorf("读取目录失败: %w", err) } for _, entry := range entries { if entry.IsDir() { subDir := filepath.Join(rootDir, entry.Name()) if _, err := os.Stat(filepath.Join(subDir, "manifest.json")); err == nil { return subDir, nil } } } return "", errors.New("未找到 manifest.json,请确保 zip 文件包含有效的 SillyTavern 扩展") } // copyDir 递归复制目录 func copyDir(src, dst string) error { if err := os.MkdirAll(dst, 0755); err != nil { return err } entries, err := os.ReadDir(src) if err != nil { return err } for _, entry := range entries { srcPath := filepath.Join(src, entry.Name()) dstPath := filepath.Join(dst, entry.Name()) if entry.IsDir() { if err := copyDir(srcPath, dstPath); err != nil { return err } } else { data, err := os.ReadFile(srcPath) if err != nil { return err } if err := os.WriteFile(dstPath, data, 0644); err != nil { return err } } } return nil }