Files
ARTEX/server/update.go
T
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

404 lines
12 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"runtime"
"sync"
"time"
"github.com/Autumn-27/artex/selfupdate"
)
// HTTP surface for one-click updates from the UI. The actual
// download/verify/swap logic lives in the selfupdate package; this file only
// handles the auth boundary, concurrency exclusion, progress broadcast, and
// telling main "time to exit".
//
// The restart is not performed by this process: after staging the new version
// the process exits with selfupdate.ExitRestart and the supervisor script
// (start.sh / start.bat, or ENTRYPOINT under Docker) brings it back up.
// restartCh is closed once an upgrade is staged or a rollback completes; main
// then exits with ExitRestart.
var (
restartOnce sync.Once
restartCh = make(chan struct{})
)
// RestartRequested returns a channel that is closed when it is time to exit and
// let the supervisor relaunch us.
func RestartRequested() <-chan struct{} { return restartCh }
func requestRestart() { restartOnce.Do(func() { close(restartCh) }) }
// bootState is selfupdate.Bootstrap's verdict for this boot (upgrade succeeded /
// just rolled back / staged file discarded), injected by main so that
// /api/update/check can faithfully tell the frontend how the last upgrade ended.
var (
bootStateMu sync.Mutex
bootState selfupdate.State
)
// SetBootUpdateState is called once by main at startup.
func SetBootUpdateState(st selfupdate.State) {
bootStateMu.Lock()
defer bootStateMu.Unlock()
bootState = st
}
func bootUpdateState() selfupdate.State {
bootStateMu.Lock()
defer bootStateMu.Unlock()
return bootState
}
// releaseCache caches the latest-release lookup against GitHub.
//
// The top-bar "new version available" indicator queries once per full page load,
// and the unauthenticated GitHub API allows 60 requests per IP per hour —
// without a cache, opening a few tabs or refreshing a few times burns through
// the quota, and a real update check would then fail to get through. When the
// user explicitly clicks "check for updates", force bypasses the cache.
type releaseCache struct {
mu sync.Mutex
rel *selfupdate.Release
err error
at time.Time
// fetch is the retrieval function, an injection point kept only for tests;
// nil means the real GitHub lookup.
fetch func(context.Context, *http.Client) (*selfupdate.Release, error)
}
const (
releaseTTL = 30 * time.Minute
// Failed results are also cached briefly, otherwise every page load would sit
// through a timeout while GitHub is unreachable; but the TTL is short so it
// heals itself soon after the network recovers.
releaseErrTTL = 2 * time.Minute
// Timeout for lookups. NewClient's 30-minute timeout is for downloading the
// full package; a version check cannot wait that long.
releaseTimeout = 20 * time.Second
)
var relCache = &releaseCache{}
// get returns the latest Release, serving from cache when possible.
//
// The lock is held for the whole fetch: concurrent requests queue up and share
// one lookup instead of each hitting GitHub (right after page load, when many
// tabs query at once, is exactly when rate limiting bites).
func (c *releaseCache) get(ctx context.Context, client *http.Client, force bool) (*selfupdate.Release, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !force {
ttl := releaseTTL
if c.err != nil {
ttl = releaseErrTTL
}
if !c.at.IsZero() && time.Since(c.at) < ttl {
return c.rel, c.err
}
}
fetch := c.fetch
if fetch == nil {
fetch = selfupdate.FetchLatest
}
ctx, cancel := context.WithTimeout(ctx, releaseTimeout)
defer cancel()
rel, err := fetch(ctx, client)
// A cancelled request (user closed the tab) does not mean GitHub is broken;
// do not write it into the cache, or the next visitor gets a baffling
// "cancelled" error.
if err != nil && ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) {
return c.rel, err
}
c.rel, c.err, c.at = rel, err, time.Now()
return rel, err
}
// updateProgress is one progress update pushed to the frontend.
type updateProgress struct {
Phase selfupdate.Phase `json:"phase"`
Percent int `json:"percent"` // only meaningful during the download phase; -1 otherwise
Message string `json:"message"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// updateHub holds the progress of one upgrade and broadcasts it to SSE
// subscribers.
//
// running doubles as a mutex: a second POST /api/update/apply during an upgrade
// gets a 409, keeping two goroutines from writing the same artex.new at once.
type updateHub struct {
mu sync.Mutex
running bool
cur updateProgress
subs map[chan updateProgress]struct{}
}
var updHub = &updateHub{
cur: updateProgress{Phase: selfupdate.PhaseIdle, Percent: -1},
subs: map[chan updateProgress]struct{}{},
}
// begin claims the upgrade slot, returning false if one is already running.
func (h *updateHub) begin(version string) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.running {
return false
}
h.running = true
h.cur = updateProgress{Phase: selfupdate.PhaseDownload, Percent: 0, Message: "Preparing...", Version: version}
h.fanout(h.cur)
return true
}
// finish ends an upgrade. A nil err means staging succeeded and a restart is
// pending.
func (h *updateHub) finish(err error) {
h.mu.Lock()
defer h.mu.Unlock()
h.running = false
if err != nil {
h.cur = updateProgress{Phase: selfupdate.PhaseFailed, Percent: -1, Message: "Update failed", Error: err.Error(), Version: h.cur.Version}
} else {
h.cur = updateProgress{Phase: selfupdate.PhaseStaged, Percent: 100, Message: "New version ready, restarting...", Version: h.cur.Version}
}
h.fanout(h.cur)
}
func (h *updateHub) publish(ph selfupdate.Phase, pct int, msg string) {
h.mu.Lock()
defer h.mu.Unlock()
h.cur = updateProgress{Phase: ph, Percent: pct, Message: msg, Version: h.cur.Version}
h.fanout(h.cur)
}
// fanout must be called with h.mu held. Subscriber channels are buffered and
// drops are fine — progress is disposable, transient information, and a stuck
// SSE connection must never block the upgrade itself.
func (h *updateHub) fanout(p updateProgress) {
for ch := range h.subs {
select {
case ch <- p:
default:
}
}
}
func (h *updateHub) snapshot() (updateProgress, bool) {
h.mu.Lock()
defer h.mu.Unlock()
return h.cur, h.running
}
func (h *updateHub) subscribe() (<-chan updateProgress, func()) {
ch := make(chan updateProgress, 64)
h.mu.Lock()
h.subs[ch] = struct{}{}
h.mu.Unlock()
var once sync.Once
return ch, func() {
once.Do(func() {
h.mu.Lock()
delete(h.subs, ch)
h.mu.Unlock()
close(ch)
})
}
}
// updateCheck queries the latest stable release on GitHub and compares it with
// the current version.
//
// The frontend also talks to api.github.com directly (GitHub's CORS is *), but
// **this endpoint is authoritative**: the download is done by the backend, so an
// update is only possible if the backend can reach GitHub. It is common for the
// browser to have connectivity while the server does not (server on an internal
// network, or the proxy configured only in the browser); in that case clicking
// update would inevitably fail, so it is better to report the error honestly at
// the check step.
func (s *Server) updateCheck(w http.ResponseWriter, r *http.Request) {
current := BuildVersion
mode := "binary"
if selfupdate.InDocker() {
mode = "docker"
}
boot := bootUpdateState()
out := map[string]any{
"current": current,
"mode": mode,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"has_backup": selfupdate.HasBackup(),
"repo": selfupdate.Repo,
"boot_notice": boot.Detail,
"rolled_back": boot.RolledBack,
}
// The top-bar indicator uses the cache (default); clicking "check for
// updates" passes force=1 to refetch from the origin.
force := r.URL.Query().Get("force") != ""
client := selfupdate.NewClient(s.m.GlobalProxy())
rel, err := relCache.get(r.Context(), client, force)
if err != nil {
out["error"] = err.Error()
writeJSON(w, 200, out)
return
}
latest := rel.TagName
out["latest"] = latest
out["notes"] = rel.Body
out["html_url"] = rel.HTMLURL
if !rel.PublishedAt.IsZero() {
out["published_at"] = rel.PublishedAt.Format(time.RFC3339)
}
asset := selfupdate.AssetName(latest, runtime.GOOS, runtime.GOARCH)
out["asset"] = asset
if a, ok := rel.FindAsset(asset); ok {
out["asset_available"] = true
out["size"] = a.Size
} else {
out["asset_available"] = false
}
cmp, comparable := selfupdate.CompareVersions(current, latest)
out["comparable"] = comparable
out["has_update"] = comparable && cmp < 0
if !comparable {
// Development builds (dev / git describe with a suffix) have no
// comparable version number. Allowing the update would only overwrite
// the binary being debugged locally with a release build, so updates
// are refused outright.
out["reason"] = fmt.Sprintf("current version %q is not a stable release; one-click update is disabled", current)
}
writeJSON(w, 200, out)
}
// updateApply downloads and stages the new version, then lets the process exit
// so the supervisor script restarts it.
//
// It returns 202 immediately and does the work on a background goroutine: a
// full package download can take minutes and would be cut off by reverse-proxy
// timeouts if tied to the request. Progress goes over /api/update/stream.
func (s *Server) updateApply(w http.ResponseWriter, r *http.Request) {
current := BuildVersion
// Use the cache: ensure the installed version is exactly the one the user
// saw and confirmed in the UI.
client := selfupdate.NewClient(s.m.GlobalProxy())
rel, err := relCache.get(r.Context(), client, false)
if err != nil {
writeErr(w, 502, err.Error())
return
}
cmp, comparable := selfupdate.CompareVersions(current, rel.TagName)
if !comparable {
writeErr(w, 400, fmt.Sprintf("current version %q is not a stable release; one-click update is disabled", current))
return
}
if cmp >= 0 {
writeErr(w, 400, fmt.Sprintf("already on the latest version %s", current))
return
}
if !updHub.begin(rel.TagName) {
writeErr(w, 409, "an update is already in progress")
return
}
go func() {
// Deliberately use s.ctx rather than the request ctx: the request ends
// as soon as the HTTP response returns, and a download tied to it would
// be cancelled immediately.
err := selfupdate.Stage(s.ctx, client, rel, current, func(ph selfupdate.Phase, pct int, msg string) {
updHub.publish(ph, pct, msg)
})
updHub.finish(err)
if err != nil {
log.Printf("[update] update failed: %v", err)
return
}
log.Printf("[update] %s → %s staged, exiting to complete the swap", current, rel.TagName)
// Leave a moment to push the final progress to the frontend before
// triggering the exit.
time.Sleep(1500 * time.Millisecond)
requestRestart()
}()
writeJSON(w, 202, map[string]any{"ok": true, "target": rel.TagName})
}
// updateRollback actively reverts to the previous version (the artex.old backed
// up before the swap).
func (s *Server) updateRollback(w http.ResponseWriter, r *http.Request) {
if _, running := updHub.snapshot(); running {
writeErr(w, 409, "an update is in progress; cannot roll back")
return
}
if err := selfupdate.Rollback(); err != nil {
writeErr(w, 400, err.Error())
return
}
log.Printf("[update] manually rolled back to the previous version, exiting to complete the switch")
writeJSON(w, 202, map[string]any{"ok": true})
go func() {
time.Sleep(500 * time.Millisecond)
requestRestart()
}()
}
// updateStream pushes update progress over SSE.
func (s *Server) updateStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, 500, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
ch, unsub := updHub.subscribe()
defer unsub()
send := func(p updateProgress) {
b, _ := json.Marshal(p)
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
// Send the current state first, so a page refresh immediately shows an
// in-flight upgrade.
cur, _ := updHub.snapshot()
send(cur)
ctx := r.Context()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case p, ok := <-ch:
if !ok {
return
}
send(p)
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
flusher.Flush()
}
}
}