Files
MokoGitea/modules/updatechecker/updatechecker.go
Jonathan Miller 13352e7213 feat: email admin when MokoGitea update is detected
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>
2026-05-25 18:24:03 -05:00

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
}