mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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
404 lines
12 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|