- 服务端保留探测标识(64 个 0):照常验签返回清单但不计入统计 - publish_api.py 新增 --verify-sparkle,JSON 发布后逐区域取回 XML 与本地签名文件逐字节比对,publish_release.sh 自动执行 - Cache-Control 探测按指令 token 判断,兼容代理合并的缓存头 - 抽出 feedRedirectPolicy 供测试复用,覆盖 HTTPS 跳转、HTTP 降级、跳转过多、404、传输错误 - 新增测试校验服务端公钥与 Info.plist、project.yml 的 SUPublicEDKey 一致 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
12 KiB
Go
322 lines
12 KiB
Go
// Copyright (C) 2026 ysicing
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package main
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"embed"
|
|
"errors"
|
|
"html/template"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/gofiber/fiber/v3/middleware/limiter"
|
|
"github.com/libtnb/sqlite"
|
|
"gorm.io/gorm"
|
|
gormlogger "gorm.io/gorm/logger"
|
|
)
|
|
|
|
var installationIDPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
|
|
|
// 发布脚本端到端核验更新源时使用的保留标识;清单照常校验返回,但不计入安装统计。
|
|
var releaseProbeInstallationID = strings.Repeat("0", 64)
|
|
|
|
func validHTTPS(raw string, optional bool) bool {
|
|
if raw == "" {
|
|
return optional
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
return err == nil && parsed.Scheme == "https" && parsed.Host != ""
|
|
}
|
|
|
|
//go:embed dashboard.html
|
|
var dashboardFiles embed.FS
|
|
|
|
var dashboardTemplate = template.Must(template.ParseFS(dashboardFiles, "dashboard.html"))
|
|
|
|
type release struct {
|
|
ID uint `json:"-" gorm:"primaryKey"`
|
|
Version string `json:"version" gorm:"uniqueIndex;size:64;not null"`
|
|
Build string `json:"build" gorm:"size:64;not null"`
|
|
Date string `json:"date" gorm:"size:32;not null"`
|
|
MinimumSystem string `json:"minimumSystem" gorm:"size:32;not null"`
|
|
URL string `json:"url" gorm:"not null"`
|
|
SHA256 string `json:"sha256" gorm:"size:64;not null"`
|
|
Size int64 `json:"size" gorm:"not null"`
|
|
DMG string `json:"dmg,omitempty"`
|
|
Notes []string `json:"notes" gorm:"serializer:json"`
|
|
Changelog string `json:"changelog,omitempty"`
|
|
IsCurrent bool `json:"-" gorm:"index;not null"`
|
|
CreatedAt time.Time `json:"-"`
|
|
UpdatedAt time.Time `json:"-"`
|
|
}
|
|
|
|
type installation struct {
|
|
ID string `gorm:"primaryKey;size:64"`
|
|
CurrentVersion string `gorm:"size:64;not null"`
|
|
Checks uint64 `gorm:"not null"`
|
|
FirstSeenAt time.Time `gorm:"not null"`
|
|
LastSeenAt time.Time `gorm:"index;not null"`
|
|
}
|
|
|
|
type updateCheck struct {
|
|
CurrentVersion string `json:"current_version"`
|
|
InstallationID string `json:"installation_id"`
|
|
}
|
|
|
|
type versionStat struct {
|
|
Version string
|
|
Count int64
|
|
Percent int
|
|
}
|
|
|
|
type dashboardData struct {
|
|
GeneratedAt string
|
|
TotalInstallations int64
|
|
ActiveInstallations int64
|
|
TotalChecks int64
|
|
CurrentVersion string
|
|
Versions []versionStat
|
|
}
|
|
|
|
type config struct {
|
|
Listen string
|
|
Database string
|
|
ReleaseToken string
|
|
}
|
|
|
|
func loadConfig(getenv func(string) string) (config, error) {
|
|
configuration := config{
|
|
Listen: getenv("XSTATS_LISTEN"),
|
|
Database: getenv("XSTATS_DATABASE"),
|
|
ReleaseToken: strings.TrimSpace(getenv("XSTATS_RELEASE_TOKEN")),
|
|
}
|
|
if configuration.Listen == "" {
|
|
configuration.Listen = ":8080"
|
|
}
|
|
if configuration.Database == "" {
|
|
configuration.Database = "xstats-api.sqlite"
|
|
}
|
|
if configuration.ReleaseToken == "" {
|
|
return config{}, errors.New("XSTATS_RELEASE_TOKEN is required")
|
|
}
|
|
return configuration, nil
|
|
}
|
|
|
|
func newApplication(databasePath string, releaseToken string) (*fiber.App, error) {
|
|
return newApplicationWithFeedClient(databasePath, releaseToken, &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
CheckRedirect: feedRedirectPolicy,
|
|
})
|
|
}
|
|
|
|
// 更新清单只允许有限次 HTTPS 跳转,降级到 HTTP 或跳转过多都视为上游故障。
|
|
func feedRedirectPolicy(request *http.Request, via []*http.Request) error {
|
|
if request.URL.Scheme != "https" || len(via) >= 5 {
|
|
return errors.New("invalid feed redirect")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newApplicationWithFeedClient(databasePath string, releaseToken string, feedClient *http.Client) (*fiber.App, error) {
|
|
databaseLogger := gormlogger.New(log.Default(), gormlogger.Config{
|
|
SlowThreshold: 500 * time.Millisecond,
|
|
LogLevel: gormlogger.Warn,
|
|
IgnoreRecordNotFoundError: true,
|
|
Colorful: false,
|
|
})
|
|
database, err := gorm.Open(
|
|
sqlite.Open(databasePath+"?_txlock=immediate&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"),
|
|
&gorm.Config{Logger: databaseLogger},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := database.AutoMigrate(&release{}, &installation{}); err != nil {
|
|
return nil, err
|
|
}
|
|
sqlDatabase, err := database.DB()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sqlDatabase.SetMaxOpenConns(1)
|
|
sqlDatabase.SetMaxIdleConns(1)
|
|
|
|
app := fiber.New(fiber.Config{
|
|
BodyLimit: 64 * 1024,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 15 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
})
|
|
app.Use(func(c fiber.Ctx) error {
|
|
c.Set(fiber.HeaderLink, `<https://github.com/ysicing/xstats>; rel="source"`)
|
|
return c.Next()
|
|
})
|
|
app.Put("/api/v1/releases/current", func(c fiber.Ctx) error {
|
|
provided := strings.TrimPrefix(c.Get(fiber.HeaderAuthorization), "Bearer ")
|
|
if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(releaseToken)) != 1 {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
|
}
|
|
var candidate release
|
|
if err := c.Bind().Body(&candidate); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
|
|
}
|
|
if candidate.Version == "" || candidate.Build == "" || candidate.Date == "" ||
|
|
candidate.MinimumSystem == "" || !validHTTPS(candidate.URL, false) || !validHTTPS(candidate.DMG, true) ||
|
|
!validHTTPS(candidate.Changelog, true) || !installationIDPattern.MatchString(candidate.SHA256) ||
|
|
candidate.Size <= 0 || len(candidate.Notes) == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid release"})
|
|
}
|
|
err := database.Transaction(func(transaction *gorm.DB) error {
|
|
if err := transaction.Model(&release{}).Where("is_current = ?", true).Update("is_current", false).Error; err != nil {
|
|
return err
|
|
}
|
|
candidate.IsCurrent = true
|
|
return transaction.Where("version = ?", candidate.Version).Assign(candidate).FirstOrCreate(&candidate).Error
|
|
})
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not publish release"})
|
|
}
|
|
return c.SendStatus(http.StatusNoContent)
|
|
})
|
|
checkLimiter := limiter.New(limiter.Config{Max: 30, Expiration: time.Minute})
|
|
app.Post("/api/v1/update/check", checkLimiter, func(c fiber.Ctx) error {
|
|
var check updateCheck
|
|
if err := c.Bind().Body(&check); err != nil || check.CurrentVersion == "" || len(check.CurrentVersion) > 64 ||
|
|
!installationIDPattern.MatchString(check.InstallationID) {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
|
|
}
|
|
if err := recordUpdateCheck(database, check); err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not record update check"})
|
|
}
|
|
|
|
var current release
|
|
if err := database.Where("is_current = ?", true).First(¤t).Error; err != nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "no release published"})
|
|
}
|
|
return c.JSON(current)
|
|
})
|
|
// 固定入口选择当前发布对应的不可变 XML;校验后原样返回,不重写或持有私钥。
|
|
// 统计与本次 GET 合并,Sparkle 客户端无需再发一份 JSON POST。
|
|
app.Get("/api/v1/update/appcast.xml", checkLimiter, func(c fiber.Ctx) error {
|
|
c.Set(fiber.HeaderCacheControl, "no-store")
|
|
check := updateCheck{CurrentVersion: c.Query("current_version"), InstallationID: c.Query("installation_id")}
|
|
if check.CurrentVersion == "" || len(check.CurrentVersion) > 64 || !installationIDPattern.MatchString(check.InstallationID) {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
|
|
}
|
|
var current release
|
|
if err := database.Where("is_current = ?", true).First(¤t).Error; err != nil {
|
|
return c.SendStatus(http.StatusServiceUnavailable)
|
|
}
|
|
asset, err := url.Parse(current.URL)
|
|
if err != nil || asset.Scheme != "https" || asset.Host == "" || !strings.HasSuffix(asset.Path, ".zip") {
|
|
return c.SendStatus(http.StatusServiceUnavailable)
|
|
}
|
|
asset.Path = strings.TrimSuffix(asset.Path, ".zip") + ".xml"
|
|
asset.RawPath, asset.RawQuery, asset.Fragment = "", "", ""
|
|
request, err := http.NewRequestWithContext(c.Context(), http.MethodGet, asset.String(), nil)
|
|
if err != nil {
|
|
return c.SendStatus(http.StatusBadGateway)
|
|
}
|
|
response, err := feedClient.Do(request)
|
|
if err != nil {
|
|
return c.SendStatus(http.StatusBadGateway)
|
|
}
|
|
defer response.Body.Close()
|
|
// 单版本清单有界读取;CDN 错误页、大响应和重定向降级不能成为更新源。
|
|
const maximumFeedBytes = 256 * 1024
|
|
if response.StatusCode != http.StatusOK || response.Request.URL.Scheme != "https" {
|
|
return c.SendStatus(http.StatusBadGateway)
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(response.Body, maximumFeedBytes+1))
|
|
if err != nil || len(data) == 0 || len(data) > maximumFeedBytes {
|
|
return c.SendStatus(http.StatusBadGateway)
|
|
}
|
|
if err := validateSparkleFeed(data, current); err != nil {
|
|
return c.SendStatus(http.StatusBadGateway)
|
|
}
|
|
if check.InstallationID != releaseProbeInstallationID {
|
|
if err := recordUpdateCheck(database, check); err != nil {
|
|
return c.SendStatus(http.StatusInternalServerError)
|
|
}
|
|
}
|
|
c.Type("xml", "utf-8")
|
|
return c.Send(data)
|
|
})
|
|
app.Get("/stats", func(c fiber.Ctx) error {
|
|
var data dashboardData
|
|
data.GeneratedAt = time.Now().Format("2006-01-02 15:04:05 MST")
|
|
if err := database.Model(&installation{}).Count(&data.TotalInstallations).Error; err != nil {
|
|
return c.SendStatus(http.StatusInternalServerError)
|
|
}
|
|
if err := database.Model(&installation{}).Where("last_seen_at >= ?", time.Now().UTC().Add(-24*time.Hour)).
|
|
Count(&data.ActiveInstallations).Error; err != nil {
|
|
return c.SendStatus(http.StatusInternalServerError)
|
|
}
|
|
if err := database.Model(&installation{}).Select("COALESCE(SUM(checks), 0)").Scan(&data.TotalChecks).Error; err != nil {
|
|
return c.SendStatus(http.StatusInternalServerError)
|
|
}
|
|
var current release
|
|
if err := database.Where("is_current = ?", true).First(¤t).Error; err == nil {
|
|
data.CurrentVersion = current.Version
|
|
}
|
|
if err := database.Model(&installation{}).
|
|
Select("current_version AS version, COUNT(*) AS count").
|
|
Group("current_version").Order("count DESC, current_version DESC").Scan(&data.Versions).Error; err != nil {
|
|
return c.SendStatus(http.StatusInternalServerError)
|
|
}
|
|
for index := range data.Versions {
|
|
if data.TotalInstallations > 0 {
|
|
data.Versions[index].Percent = int(data.Versions[index].Count * 100 / data.TotalInstallations)
|
|
}
|
|
}
|
|
|
|
c.Type("html", "utf-8")
|
|
return dashboardTemplate.ExecuteTemplate(c.Response().BodyWriter(), "dashboard.html", data)
|
|
})
|
|
return app, nil
|
|
}
|
|
|
|
func recordUpdateCheck(database *gorm.DB, check updateCheck) error {
|
|
now := time.Now().UTC()
|
|
return database.Transaction(func(transaction *gorm.DB) error {
|
|
var item installation
|
|
result := transaction.First(&item, "id = ?", check.InstallationID)
|
|
if result.Error != nil && result.Error != gorm.ErrRecordNotFound {
|
|
return result.Error
|
|
}
|
|
if result.Error == gorm.ErrRecordNotFound {
|
|
item = installation{ID: check.InstallationID, CurrentVersion: check.CurrentVersion, Checks: 1, FirstSeenAt: now, LastSeenAt: now}
|
|
return transaction.Create(&item).Error
|
|
}
|
|
return transaction.Model(&item).Updates(map[string]any{
|
|
"current_version": check.CurrentVersion,
|
|
"last_seen_at": now,
|
|
"checks": gorm.Expr("checks + 1"),
|
|
}).Error
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
configuration, err := loadConfig(os.Getenv)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
app, err := newApplication(configuration.Database, configuration.ReleaseToken)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
log.Printf("XStats API listening on %s", configuration.Listen)
|
|
if err := app.Listen(configuration.Listen, fiber.ListenConfig{DisableStartupMessage: true}); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|