2026-08-03 16:16:20 +08:00
|
|
|
package app
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"regexp"
|
|
|
|
|
"sort"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
BuildVersion = "dev"
|
|
|
|
|
BuildCommit = ""
|
|
|
|
|
BuildDate = ""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type systemVersionInfo struct {
|
|
|
|
|
CurrentVersion string `json:"currentVersion"`
|
|
|
|
|
CurrentCommit string `json:"currentCommit,omitempty"`
|
|
|
|
|
BuildDate string `json:"buildDate,omitempty"`
|
|
|
|
|
LatestVersion string `json:"latestVersion,omitempty"`
|
|
|
|
|
LatestName string `json:"latestName,omitempty"`
|
|
|
|
|
ReleaseURL string `json:"releaseUrl,omitempty"`
|
|
|
|
|
ReleaseNotes string `json:"releaseNotes,omitempty"`
|
|
|
|
|
PublishedAt *time.Time `json:"publishedAt,omitempty"`
|
|
|
|
|
UpdateAvailable bool `json:"updateAvailable"`
|
|
|
|
|
UpdateEnabled bool `json:"updateEnabled"`
|
|
|
|
|
CheckError string `json:"checkError,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type githubRelease struct {
|
|
|
|
|
TagName string `json:"tag_name"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
HTMLURL string `json:"html_url"`
|
|
|
|
|
Body string `json:"body"`
|
|
|
|
|
PublishedAt time.Time `json:"published_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleSystemVersion(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
info, err := a.systemVersion(r.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
info.CheckError = "暂时无法连接版本服务"
|
|
|
|
|
a.log.Warn("check system version", "error", err)
|
|
|
|
|
}
|
|
|
|
|
respondJSON(w, http.StatusOK, info)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user := currentUser(r)
|
|
|
|
|
if user == nil || user.Role != "admin" {
|
|
|
|
|
respondError(w, http.StatusForbidden, "system administrator required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !a.updateEnabled() {
|
|
|
|
|
respondError(w, http.StatusServiceUnavailable, "online update is not configured")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
info, err := a.systemVersion(r.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, http.StatusBadGateway, "failed to check latest release")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !info.UpdateAvailable {
|
|
|
|
|
respondError(w, http.StatusConflict, "already on the latest version")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
backupPath, err := a.backupDatabaseBeforeUpdate(r.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.log.Error("backup database before update", "error", err)
|
|
|
|
|
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := a.triggerUpdateService(r.Context()); err != nil {
|
|
|
|
|
a.log.Error("trigger system update", "error", err)
|
|
|
|
|
respondError(w, http.StatusBadGateway, "failed to start update")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
|
|
|
|
respondJSON(w, http.StatusAccepted, map[string]any{
|
|
|
|
|
"ok": true,
|
|
|
|
|
"currentVersion": info.CurrentVersion,
|
|
|
|
|
"targetVersion": info.LatestVersion,
|
|
|
|
|
"message": "更新已启动,服务会在完成后自动恢复",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
2026-08-03 19:51:27 +08:00
|
|
|
current := strings.TrimSpace(a.config().AppVersion)
|
2026-08-03 16:16:20 +08:00
|
|
|
if current == "" {
|
|
|
|
|
current = BuildVersion
|
|
|
|
|
}
|
|
|
|
|
info := systemVersionInfo{
|
|
|
|
|
CurrentVersion: current,
|
|
|
|
|
CurrentCommit: strings.TrimSpace(BuildCommit),
|
|
|
|
|
BuildDate: strings.TrimSpace(BuildDate),
|
|
|
|
|
UpdateEnabled: a.updateEnabled(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
release, err := a.fetchLatestRelease(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return info, err
|
|
|
|
|
}
|
|
|
|
|
info.LatestVersion = strings.TrimSpace(release.TagName)
|
|
|
|
|
info.LatestName = strings.TrimSpace(release.Name)
|
|
|
|
|
info.ReleaseURL = strings.TrimSpace(release.HTMLURL)
|
|
|
|
|
info.ReleaseNotes = strings.TrimSpace(release.Body)
|
|
|
|
|
if !release.PublishedAt.IsZero() {
|
|
|
|
|
info.PublishedAt = &release.PublishedAt
|
|
|
|
|
}
|
|
|
|
|
info.UpdateAvailable = versionIsNewer(info.LatestVersion, info.CurrentVersion)
|
|
|
|
|
return info, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
2026-08-03 19:51:27 +08:00
|
|
|
endpoint := strings.TrimSpace(a.config().ReleaseAPIURL)
|
2026-08-03 16:16:20 +08:00
|
|
|
parsed, err := url.Parse(endpoint)
|
|
|
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
|
|
|
return githubRelease{}, errors.New("invalid release API URL")
|
|
|
|
|
}
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return githubRelease{}, err
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
2026-08-03 19:51:27 +08:00
|
|
|
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.config().AppVersion, "v"))
|
2026-08-03 16:16:20 +08:00
|
|
|
client := &http.Client{
|
|
|
|
|
Timeout: 8 * time.Second,
|
|
|
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
|
|
|
return http.ErrUseLastResponse
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return githubRelease{}, err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
|
|
|
|
return githubRelease{}, fmt.Errorf("release API returned %s", resp.Status)
|
|
|
|
|
}
|
|
|
|
|
var release githubRelease
|
|
|
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&release); err != nil {
|
|
|
|
|
return githubRelease{}, err
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(release.TagName) == "" {
|
|
|
|
|
return githubRelease{}, errors.New("release API returned an empty tag")
|
|
|
|
|
}
|
|
|
|
|
return release, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) updateEnabled() bool {
|
2026-08-03 19:51:27 +08:00
|
|
|
return strings.TrimSpace(a.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
|
2026-08-03 16:16:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) triggerUpdateService(ctx context.Context) error {
|
2026-08-03 19:51:27 +08:00
|
|
|
parsed, err := url.Parse(strings.TrimSpace(a.config().UpdateServiceURL))
|
2026-08-03 16:16:20 +08:00
|
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
|
|
|
return errors.New("invalid update service URL")
|
|
|
|
|
}
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-03 19:51:27 +08:00
|
|
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
2026-08-03 16:16:20 +08:00
|
|
|
client := &http.Client{
|
|
|
|
|
Timeout: 30 * time.Second,
|
|
|
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
|
|
|
return http.ErrUseLastResponse
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
|
|
|
return fmt.Errorf("update service returned %s", resp.Status)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
2026-08-03 19:51:27 +08:00
|
|
|
backupDir := filepath.Join(a.config().DataDir, "backups")
|
2026-08-03 16:16:20 +08:00
|
|
|
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
backupPath := filepath.Join(backupDir, "pre-update-"+a.now().UTC().Format("20060102T150405.000000000Z")+".db")
|
|
|
|
|
quotedPath := strings.ReplaceAll(backupPath, "'", "''")
|
|
|
|
|
if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quotedPath+"'"); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if err := pruneUpdateBackups(backupDir, 5); err != nil {
|
|
|
|
|
a.log.Warn("prune update backups", "error", err)
|
|
|
|
|
}
|
|
|
|
|
return backupPath, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func pruneUpdateBackups(dir string, keep int) error {
|
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
type backupFile struct {
|
|
|
|
|
path string
|
|
|
|
|
modTime time.Time
|
|
|
|
|
}
|
|
|
|
|
backups := make([]backupFile, 0, len(entries))
|
|
|
|
|
for _, entry := range entries {
|
|
|
|
|
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pre-update-") || !strings.HasSuffix(entry.Name(), ".db") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
info, err := entry.Info()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()})
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(backups, func(i, j int) bool { return backups[i].modTime.After(backups[j].modTime) })
|
|
|
|
|
if keep < 0 {
|
|
|
|
|
keep = 0
|
|
|
|
|
}
|
|
|
|
|
if len(backups) <= keep {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
for _, backup := range backups[keep:] {
|
|
|
|
|
if err := os.Remove(backup.path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var versionPattern = regexp.MustCompile(`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
|
|
|
|
|
|
|
|
|
|
func versionIsNewer(latest, current string) bool {
|
|
|
|
|
latestParts, latestPrerelease, latestOK := parseVersion(latest)
|
|
|
|
|
currentParts, currentPrerelease, currentOK := parseVersion(current)
|
|
|
|
|
if !latestOK {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if !currentOK {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
for i := 0; i < len(latestParts); i++ {
|
|
|
|
|
if latestParts[i] != currentParts[i] {
|
|
|
|
|
return latestParts[i] > currentParts[i]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return currentPrerelease != "" && latestPrerelease == ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseVersion(value string) ([3]int, string, bool) {
|
|
|
|
|
match := versionPattern.FindStringSubmatch(strings.TrimSpace(value))
|
|
|
|
|
if match == nil {
|
|
|
|
|
return [3]int{}, "", false
|
|
|
|
|
}
|
|
|
|
|
var parts [3]int
|
|
|
|
|
for i := 0; i < 3; i++ {
|
|
|
|
|
if match[i+1] == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
part, err := strconv.Atoi(match[i+1])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return [3]int{}, "", false
|
|
|
|
|
}
|
|
|
|
|
parts[i] = part
|
|
|
|
|
}
|
|
|
|
|
return parts, match[4], true
|
|
|
|
|
}
|