package server import ( "fmt" "net/http" "os" "path/filepath" "runtime" "strings" "time" "gitcat/internal/store" "gitcat/internal/upgrade" ) // 升级相关的设置键。 const ( settingUpgradeURL = "upgrade_url" settingUpgradeAutoApply = "upgrade_auto_apply" ) // upgradeInfo 返回当前二进制的版本信息。 func (s *Server) upgradeInfo() upgrade.Info { return upgrade.Info{ Version: s.cfg.Version, Commit: s.cfg.Commit, BuildDate: s.cfg.BuildDate, ExePath: s.cfg.ExePath, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, } } func (s *Server) handleAdminUpgradePage(w http.ResponseWriter, r *http.Request) { if s.requireAdmin(w, r) == nil { return } info := s.upgradeInfo() p := s.page(r, "版本与升级") p.Active = "admin" p.Data["Tab"] = "upgrade" p.Data["Version"] = info.Version p.Data["Commit"] = info.ShortSHA() p.Data["HasCommit"] = info.Commit != "" p.Data["BuildDate"] = info.BuildDate p.Data["Platform"] = info.GOOS + "/" + info.GOARCH p.Data["IsDev"] = info.IsDev() p.Data["ExePath"] = info.ExePath p.Data["SchemaVersion"] = s.st.SchemaVersion() p.Data["SchemaSupported"] = store.SchemaVersion p.Data["UpgradeURL"] = s.st.GetSetting(settingUpgradeURL) p.Data["AutoApply"] = s.st.GetSetting(settingUpgradeAutoApply) == "1" p.Data["RestartSupported"] = s.cfg.Restart != nil p.Data["ExeReady"] = s.upgradeExeExists() p.Data["LastCheck"] = s.st.GetSetting("upgrade_checked_at") p.Data["LastCheckText"] = humanTime(s.st.GetSetting("upgrade_checked_at")) p.Data["BackupDir"] = filepath.Join(s.cfg.DataDir, "backup-bin") if pend := upgrade.ReadPending(s.cfg.DataDir); pend != nil { p.Data["Pending"] = pend p.Data["PendingFrom"] = pend.FromVersion p.Data["PendingTo"] = pend.ToVersion p.Data["PendingBackup"] = pend.BackupPath p.Data["PendingAt"] = pend.AppliedAt } s.render(w, r, "admin_upgrade", p) } // handleAdminUpgradeAction 统一处理升级页的三种动作。 // // 用 POST + CSRF 而不是可被预取的 GET:升级会替换服务端的可执行文件, // 必须确认请求来自已登录的管理员。 func (s *Server) handleAdminUpgradeAction(w http.ResponseWriter, r *http.Request) { if s.requireAdmin(w, r) == nil { return } if !s.checkCSRF(r) { s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试") return } action := r.FormValue("action") target := "/admin/upgrade" switch action { case "save": url := strings.TrimSpace(r.FormValue("upgrade_url")) if url != "" && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { url = "https://" + url } if url != "" && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { setFlash(w, "升级源必须是 http/https 地址") http.Redirect(w, r, target, http.StatusFound) return } _ = s.st.SetSetting(settingUpgradeURL, url) _ = s.st.SetSetting(settingUpgradeAutoApply, boolSetting(r.FormValue("auto_apply") == "on")) setFlash(w, "升级设置已保存") http.Redirect(w, r, target, http.StatusFound) case "check": res, err := s.upgradeInfo().Check(r.Context(), s.st.GetSetting(settingUpgradeURL)) if err != nil { setFlash(w, "检查更新失败:"+err.Error()) http.Redirect(w, r, target, http.StatusFound) return } _ = s.st.SetSetting("upgrade_checked_at", res.CheckedAt.Format(time.RFC3339)) _ = s.st.SetSetting("upgrade_latest", res.Latest) msg := res.Message if res.Upgradable && res.Release != nil { msg = fmt.Sprintf("发现新版本 %s,当前 %s", res.Release.Version, res.Current) if res.Release.Notes != "" { msg += ":" + res.Release.Notes } } setFlash(w, msg) http.Redirect(w, r, target, http.StatusFound) case "apply": s.applyUpgrade(w, r, target) case "restart": s.triggerRestart(w, r, target) default: http.Redirect(w, r, target, http.StatusFound) } } // applyUpgrade 执行下载 → 校验 → 替换。 func (s *Server) applyUpgrade(w http.ResponseWriter, r *http.Request, target string) { if !s.upgrading.CompareAndSwap(false, true) { setFlash(w, "已有升级任务在进行中,请稍候") http.Redirect(w, r, target, http.StatusFound) return } defer s.upgrading.Store(false) // 危险操作二次确认:要求管理员输入当前版本号。 if want := strings.TrimSpace(r.FormValue("confirm")); want != s.cfg.Version { setFlash(w, "请输入当前版本号 "+s.cfg.Version+" 以确认升级") http.Redirect(w, r, target, http.StatusFound) return } manifestURL := s.st.GetSetting(settingUpgradeURL) if manifestURL == "" { setFlash(w, "请先配置升级源") http.Redirect(w, r, target, http.StatusFound) return } info := s.upgradeInfo() res, err := info.Check(r.Context(), manifestURL) if err != nil { setFlash(w, "检查更新失败:"+err.Error()) http.Redirect(w, r, target, http.StatusFound) return } if !res.Upgradable || res.Release == nil { msg := res.Message if msg == "" { msg = "没有可安装的新版本" } setFlash(w, msg) http.Redirect(w, r, target, http.StatusFound) return } backupDir := filepath.Join(s.cfg.DataDir, "backup-bin") inst, err := upgrade.Install(s.cfg.DataDir, info.ExePath, backupDir, res.Release, s.cfg.Version) if err != nil { setFlash(w, "升级失败:"+err.Error()) http.Redirect(w, r, target, http.StatusFound) return } _ = s.st.SetSetting("upgrade_history", time.Now().Format(time.RFC3339)+" → "+inst.ToVersion) if inst.Note != "" { setFlash(w, inst.Note) http.Redirect(w, r, target, http.StatusFound) return } if s.cfg.Restart == nil { setFlash(w, fmt.Sprintf("已升级到 %s,请重启服务后生效(旧程序备份在 %s)", inst.ToVersion, inst.BackupPath)) http.Redirect(w, r, target, http.StatusFound) return } setFlash(w, fmt.Sprintf("已升级到 %s,正在重启…", inst.ToVersion)) http.Redirect(w, r, target, http.StatusFound) // 响应已经写出,goroutine 里不能再碰 w —— 只负责稍后触发重启。 scheduleRestart(s.cfg.Restart) } // scheduleRestart 延迟触发重启,留出时间把响应刷给浏览器。 func scheduleRestart(restart func()) { go func() { time.Sleep(time.Second) restart() }() } // triggerRestart 触发优雅重启。 func (s *Server) triggerRestart(w http.ResponseWriter, r *http.Request, target string) { if s.cfg.Restart == nil { setFlash(w, "当前部署方式无法自动重启,请手工重启服务") http.Redirect(w, r, target, http.StatusFound) return } if !s.upgrading.CompareAndSwap(false, true) { http.Redirect(w, r, target, http.StatusFound) return } setFlash(w, "服务正在重启,请稍候几秒后刷新页面") http.Redirect(w, r, target, http.StatusFound) if f, ok := w.(http.Flusher); ok { f.Flush() } scheduleRestart(s.cfg.Restart) } // upgradeExeExists 用于页面提示:可执行文件是否可定位。 // humanTime 把 RFC3339 字符串渲染成人话;解析失败原样返回。 func humanTime(s string) string { if s == "" { return "从未" } t, err := time.Parse(time.RFC3339, s) if err != nil { return s } return t.Local().Format("2006-01-02 15:04") } func (s *Server) upgradeExeExists() bool { if s.cfg.ExePath == "" { return false } _, err := os.Stat(s.cfg.ExePath) return err == nil }