// Copyright 2026 Moko Consulting // SPDX-License-Identifier: GPL-3.0-or-later package updatechecker import ( "encoding/xml" "fmt" "io" "net/http" "strings" "sync" "time" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" ) // UpdateInfo holds the result of the latest update check. type UpdateInfo struct { UpdateAvailable bool LatestVersion string ReleaseURL string DockerImage string Channel string CheckedAt time.Time } // NotifyFunc is called when a new update is detected for the first time. // Set this from the cron/mailer layer to send admin email notifications. var NotifyFunc func(info *UpdateInfo) var ( cachedInfo *UpdateInfo lastNotifiedVer string mu sync.RWMutex ) // xmlUpdates mirrors the updates.xml structure (Joomla-style). type xmlUpdates struct { XMLName xml.Name `xml:"updates"` Updates []xmlUpdate `xml:"update"` } type xmlUpdate struct { Name string `xml:"name"` Version string `xml:"version"` Tags xmlTags `xml:"tags"` InfoURL xmlInfoURL `xml:"infourl"` Downloads xmlDownloads `xml:"downloads"` Maintainer string `xml:"maintainer"` Description string `xml:"description"` } type xmlTags struct { Tag string `xml:"tag"` } type xmlInfoURL struct { Title string `xml:"title,attr"` URL string `xml:",chardata"` } type xmlDownloads struct { DownloadURL []xmlDownloadURL `xml:"downloadurl"` } type xmlDownloadURL struct { Type string `xml:"type,attr"` Format string `xml:"format,attr"` URL string `xml:",chardata"` } // CheckForUpdate fetches updates.xml from the configured endpoint, // filters by the selected channel, and compares to the running version. func CheckForUpdate() error { if !setting.UpdateChecker.Enabled || setting.UpdateChecker.Endpoint == "" { return nil } channel := setting.UpdateChecker.Channel if channel == "" { channel = "stable" } client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Get(setting.UpdateChecker.Endpoint) if err != nil { return fmt.Errorf("update check failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("update check returned status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("reading update response: %w", err) } var updates xmlUpdates if err := xml.Unmarshal(body, &updates); err != nil { return fmt.Errorf("parsing updates.xml: %w", err) } // Find the entry matching the selected channel var matched *xmlUpdate for i := range updates.Updates { if strings.EqualFold(updates.Updates[i].Tags.Tag, channel) { matched = &updates.Updates[i] break } } if matched == nil { log.Debug("No update entry found for channel %q", channel) return nil } latestVersion := matched.Version currentVersion := setting.AppVer // Extract docker image URL if available dockerImage := "" for _, dl := range matched.Downloads.DownloadURL { if dl.Format == "docker" { dockerImage = strings.TrimSpace(dl.URL) break } } info := &UpdateInfo{ LatestVersion: latestVersion, ReleaseURL: strings.TrimSpace(matched.InfoURL.URL), DockerImage: dockerImage, Channel: channel, CheckedAt: time.Now(), } // Update is available if the latest version string is not a prefix of the current version. info.UpdateAvailable = latestVersion != "" && !strings.Contains(currentVersion, latestVersion) mu.Lock() cachedInfo = info // Notify only once per new version (avoid spamming on every cron tick) shouldNotify := info.UpdateAvailable && latestVersion != lastNotifiedVer if shouldNotify { lastNotifiedVer = latestVersion } mu.Unlock() if info.UpdateAvailable { log.Info("MokoGitea update available: %s [%s] (current: %s)", latestVersion, channel, currentVersion) if shouldNotify && NotifyFunc != nil { NotifyFunc(info) } } else { log.Debug("MokoGitea is up to date: %s [%s]", currentVersion, channel) } return nil } // GetUpdateInfo returns the cached update check result. func GetUpdateInfo() *UpdateInfo { mu.RLock() defer mu.RUnlock() if cachedInfo == nil { return &UpdateInfo{} } return cachedInfo }