13352e7213
The update checker now emails the first admin user when a new version is found on the configured channel. Notifications are deduplicated — only sent once per new version, not on every cron tick. - Added NotifyFunc callback in updatechecker module - Wired to mailer in cron task registration - Created mail_update.go with plain-text email including version, channel, release URL, and docker pull command Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
174 lines
4.2 KiB
Go
174 lines
4.2 KiB
Go
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package updatechecker
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
|
"git.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
|
|
}
|