Server: - pkg/updatecheck: GitHub release check (filtered to v* server tags — desktop-v*/android-v* share the repo), semver compare, KV-cached 6h - Self-update: download release asset, verify sha256 against checksums.txt, extract binary, atomic swap - Unix: re-exec via syscall.Exec (preserves argv/env); Windows: staged .new + detached cmd helper that waits for exit, swaps, restarts with original args and cwd - Container detection marks self-update unsupported - GET/POST /admin/tool/update (POST requires ScopeAdminWrite); apply responds before the 800ms-delayed swap so the UI gets confirmation - Admin Home banner: version + release notes dialog, Update now / View release; update.* keys in all 13 dashboard locales Desktop: - Startup update prompt now opens the update window with ?auto=1 — download starts immediately, version shown during progress - Manual check still requires an explicit Update now click Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3593/head
parent
d18d8dfdc1
commit
164431527e
@ -0,0 +1,140 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { applyServerUpdate, getDashboardSummary, getServerUpdateInfo } from "../../../api/api.ts";
|
||||
import { UpdateInfo } from "../../../api/dashboard.ts";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { DefaultButton, DenseFilledTextField } from "../../Common/StyledComponents.tsx";
|
||||
|
||||
const UpdateNotice = () => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const dispatch = useAppDispatch();
|
||||
const [info, setInfo] = useState<UpdateInfo>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getServerUpdateInfo())
|
||||
.then(setInfo)
|
||||
.catch(() => {
|
||||
// Check failures (offline, rate limit) are silent — the banner
|
||||
// simply does not appear.
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
const doUpdate = async () => {
|
||||
setApplyError(undefined);
|
||||
setUpdating(true);
|
||||
try {
|
||||
await dispatch(applyServerUpdate());
|
||||
} catch (e) {
|
||||
setApplyError(String(e));
|
||||
setUpdating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The process restarts itself once the binary is swapped; poll until it
|
||||
// answers again, then reload so the UI picks up the new build.
|
||||
const started = Date.now();
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await dispatch(getDashboardSummary(false));
|
||||
if (s.version.version === info?.version) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// still restarting
|
||||
}
|
||||
if (Date.now() - started < 120_000) {
|
||||
setTimeout(poll, 3000);
|
||||
} else {
|
||||
setApplyError(t("update.timeout"));
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
setTimeout(poll, 4000);
|
||||
};
|
||||
|
||||
if (!info?.newer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{ mb: 2 }}
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={() => setOpen(true)}>
|
||||
{t("update.title")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{t("update.available", { version: info.version, current: info.current })}
|
||||
</Alert>
|
||||
|
||||
<DraggableDialog
|
||||
title={t("update.title")}
|
||||
dialogProps={{ open, onClose: () => setOpen(false), fullWidth: true, maxWidth: "sm" }}
|
||||
>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t("update.available", { version: info.version, current: info.current })}
|
||||
</Typography>
|
||||
{info.notes && (
|
||||
<DenseFilledTextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={4}
|
||||
maxRows={10}
|
||||
value={info.notes}
|
||||
slotProps={{ input: { readOnly: true } }}
|
||||
/>
|
||||
)}
|
||||
{updating && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
{t("update.updating")}
|
||||
</Alert>
|
||||
)}
|
||||
{applyError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{applyError}
|
||||
</Alert>
|
||||
)}
|
||||
{info.container && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
{t("update.containerHint")}
|
||||
</Alert>
|
||||
)}
|
||||
{!info.self_update && !info.container && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
{t("update.unsupported")}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} disabled={updating}>
|
||||
{t("common:close")}
|
||||
</Button>
|
||||
<Button onClick={() => window.open(info.url)}>{t("update.viewRelease")}</Button>
|
||||
{info.self_update && (
|
||||
<DefaultButton variant="contained" onClick={doUpdate} disabled={updating}>
|
||||
{updating ? t("update.updating") : t("update.updateNow")}
|
||||
</DefaultButton>
|
||||
)}
|
||||
</DialogActions>
|
||||
</DraggableDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateNotice;
|
||||
@ -0,0 +1,194 @@
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const releaseBase = "https://github.com/" + ReleaseRepo + "/releases/download/"
|
||||
|
||||
// ApplyUpdate downloads the release asset matching this platform, verifies it
|
||||
// against checksums.txt, replaces the running binary and restarts the process.
|
||||
// On success it does not return (the process image is replaced / exited).
|
||||
func ApplyUpdate(ctx context.Context, rel *ReleaseInfo) error {
|
||||
asset, err := platformAssetName("v" + rel.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "cloudreve-update-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
archivePath := filepath.Join(tmp, asset)
|
||||
if err := downloadTo(ctx, releaseBase+"v"+rel.Version+"/"+asset, archivePath); err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
sumPath := filepath.Join(tmp, "checksums.txt")
|
||||
if err := downloadTo(ctx, releaseBase+"v"+rel.Version+"/checksums.txt", sumPath); err != nil {
|
||||
return fmt.Errorf("checksum download failed: %w", err)
|
||||
}
|
||||
if err := verifyChecksum(sumPath, asset, archivePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binName := "cloudreve"
|
||||
if runtime.GOOS == "windows" {
|
||||
binName = "cloudreve.exe"
|
||||
}
|
||||
newBin := filepath.Join(tmp, binName)
|
||||
if err := extractBinary(archivePath, binName, newBin); err != nil {
|
||||
return fmt.Errorf("extract failed: %w", err)
|
||||
}
|
||||
|
||||
if err := installAndRestart(newBin); err != nil {
|
||||
return err
|
||||
}
|
||||
// Windows staged a swap helper — exit so it can replace the binary.
|
||||
// os.Exit skips defers, so clean the temp dir explicitly first.
|
||||
// (Unix never reaches here: syscall.Exec replaced the process image.)
|
||||
if runtime.GOOS == "windows" {
|
||||
_ = os.RemoveAll(tmp)
|
||||
os.Exit(0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadTo streams url into path.
|
||||
func downloadTo(ctx context.Context, url, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "cloudreve-update-check")
|
||||
resp, err := (&http.Client{Timeout: downloadTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// verifyChecksum finds the asset's sha256 in checksums.txt and compares it to
|
||||
// the downloaded file's digest.
|
||||
func verifyChecksum(sumFile, asset, file string) error {
|
||||
data, err := os.ReadFile(sumFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var want string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
// format: "<sha256> <filename>"
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 2 && fields[1] == asset {
|
||||
want = fields[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
if want == "" {
|
||||
return fmt.Errorf("no checksum entry for %s", asset)
|
||||
}
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return err
|
||||
}
|
||||
if got := hex.EncodeToString(h.Sum(nil)); !strings.EqualFold(got, want) {
|
||||
return fmt.Errorf("checksum mismatch: expected %s, got %s", want, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractBinary pulls binName out of a .tar.gz or .zip archive into dst with
|
||||
// executable permissions.
|
||||
func extractBinary(archive, binName, dst string) error {
|
||||
if strings.HasSuffix(archive, ".zip") {
|
||||
return extractZip(archive, binName, dst)
|
||||
}
|
||||
return extractTarGz(archive, binName, dst)
|
||||
}
|
||||
|
||||
func extractTarGz(archive, binName, dst string) error {
|
||||
f, err := os.Open(archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg || filepath.Base(hdr.Name) != binName {
|
||||
continue
|
||||
}
|
||||
return writeExecutable(dst, tr)
|
||||
}
|
||||
return fmt.Errorf("%s not found in archive", binName)
|
||||
}
|
||||
|
||||
func extractZip(archive, binName, dst string) error {
|
||||
zr, err := zip.OpenReader(archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, zf := range zr.File {
|
||||
if zf.FileInfo().IsDir() || filepath.Base(zf.Name) != binName {
|
||||
continue
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
return writeExecutable(dst, rc)
|
||||
}
|
||||
return fmt.Errorf("%s not found in archive", binName)
|
||||
}
|
||||
|
||||
func writeExecutable(dst string, r io.Reader) error {
|
||||
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, r)
|
||||
return err
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
//go:build !windows
|
||||
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// installAndRestart swaps the running binary for newBin, then re-executes the
|
||||
// process image in place — no supervisor needed. On success it never returns;
|
||||
// on failure the original binary is restored and an error is returned.
|
||||
func installAndRestart(newBin string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
|
||||
bak := exe + ".bak"
|
||||
_ = os.Remove(bak)
|
||||
if err := os.Rename(exe, bak); err != nil {
|
||||
return fmt.Errorf("cannot move aside current binary: %w", err)
|
||||
}
|
||||
if err := os.Rename(newBin, exe); err != nil {
|
||||
// rename across filesystems can fail — fall back to copy
|
||||
if err := copyFile(newBin, exe); err != nil {
|
||||
_ = os.Rename(bak, exe)
|
||||
return fmt.Errorf("cannot install new binary: %w", err)
|
||||
}
|
||||
}
|
||||
_ = os.Chmod(exe, 0o755)
|
||||
|
||||
if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil {
|
||||
// exec failed — restore the old binary and report
|
||||
_ = os.Remove(exe)
|
||||
_ = os.Rename(bak, exe)
|
||||
return fmt.Errorf("re-exec failed: %w", err)
|
||||
}
|
||||
return nil // unreachable
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func unameMachine() (string, error) {
|
||||
out, err := exec.Command("uname", "-m").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
//go:build windows
|
||||
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// installAndRestart stages newBin next to the running executable and hands off
|
||||
// to a detached cmd script that waits for this process to exit, swaps the
|
||||
// binary and starts it again. Returns an error if staging fails; on success the
|
||||
// caller should exit the process.
|
||||
func installAndRestart(newBin string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
|
||||
staged := exe + ".new"
|
||||
if err := copyFile(newBin, staged); err != nil {
|
||||
return fmt.Errorf("cannot stage new binary: %w", err)
|
||||
}
|
||||
|
||||
// Preserve the original arguments and working directory — a bare
|
||||
// `start "" exe` would lose e.g. `-c conf.ini` on restart.
|
||||
var args strings.Builder
|
||||
for _, a := range os.Args[1:] {
|
||||
fmt.Fprintf(&args, ` "%s"`, strings.ReplaceAll(a, `"`, ""))
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
// Helper waits for our PID to exit, swaps the binary, restarts it, then
|
||||
// deletes itself.
|
||||
script := exe + ".update.cmd"
|
||||
content := fmt.Sprintf(`@echo off
|
||||
:wait
|
||||
tasklist /FI "PID eq %d" 2>nul | find "%d" >nul
|
||||
if %%errorlevel%%==0 (timeout /t 1 /nobreak >nul & goto wait)
|
||||
move /y "%s" "%s" >nul
|
||||
cd /d "%s"
|
||||
start "" "%s"%s
|
||||
del "%s"
|
||||
`, os.Getpid(), os.Getpid(), staged, exe, cwd, exe, args.String(), script)
|
||||
if err := os.WriteFile(script, []byte(content), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write update helper: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("cmd", "/C", "start", "", "/min", script)
|
||||
cmd.Dir = filepath.Dir(exe)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("cannot start update helper: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
// unameMachine is unused on Windows (arm32 is not a supported server target).
|
||||
func unameMachine() (string, error) {
|
||||
return "", fmt.Errorf("not supported")
|
||||
}
|
||||
@ -0,0 +1,224 @@
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/constants"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
// ReleaseRepo is the GitHub repository releases are fetched from.
|
||||
ReleaseRepo = "Dvorinka/cloudreve"
|
||||
releaseAPI = "https://api.github.com/repos/" + ReleaseRepo + "/releases"
|
||||
releaseTTL = 6 * time.Hour
|
||||
cacheKey = "server_update_latest"
|
||||
checkTimeout = 15 * time.Second
|
||||
downloadTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// serverTag matches server release tags (v4.19.2, v4.19.2-rc1) and rejects the
|
||||
// desktop-v* / android-v* tag families living in the same repository.
|
||||
var serverTag = regexp.MustCompile(`^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$`)
|
||||
|
||||
func init() {
|
||||
// Required for *ReleaseInfo to survive a round trip through the
|
||||
// gob-encoded cache driver (Redis).
|
||||
gob.Register(&ReleaseInfo{})
|
||||
}
|
||||
|
||||
// ReleaseInfo describes the newest upstream release.
|
||||
type ReleaseInfo struct {
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url"`
|
||||
Notes string `json:"notes"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Newer bool `json:"newer"`
|
||||
Current string `json:"current"`
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// LatestRelease queries GitHub for the newest server release. Results are
|
||||
// cached in the KV store for releaseTTL so admin page loads do not hammer the
|
||||
// API. A nil kv skips the cache. Failures return an error.
|
||||
func LatestRelease(ctx context.Context, kv cache.Driver) (*ReleaseInfo, error) {
|
||||
if kv != nil {
|
||||
if cached, ok := kv.Get(cacheKey); ok {
|
||||
if rel, ok := cached.(*ReleaseInfo); ok {
|
||||
return rel, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseAPI, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "cloudreve-update-check")
|
||||
|
||||
client := &http.Client{Timeout: checkTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("release check failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var releases []ghRelease
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The repo hosts server (v*), desktop (desktop-v*) and Android (android-v*)
|
||||
// tag families; the list is newest-first so the first matching server tag
|
||||
// is the latest server release.
|
||||
var gh *ghRelease
|
||||
for i := range releases {
|
||||
if serverTag.MatchString(releases[i].TagName) {
|
||||
gh = &releases[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if gh == nil {
|
||||
return nil, fmt.Errorf("no server release found")
|
||||
}
|
||||
|
||||
rel := &ReleaseInfo{
|
||||
Version: strings.TrimPrefix(gh.TagName, "v"),
|
||||
URL: gh.HTMLURL,
|
||||
Notes: gh.Body,
|
||||
PublishedAt: gh.PublishedAt,
|
||||
Current: strings.TrimPrefix(constants.BackendVersion, "v"),
|
||||
}
|
||||
rel.Newer = IsNewer(rel.Current, rel.Version)
|
||||
|
||||
if kv != nil {
|
||||
_ = kv.Set(cacheKey, rel, int(releaseTTL.Seconds()))
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// IsNewer reports whether latest is a strictly newer version than current.
|
||||
// Versions are compared numerically per component; pre-release suffixes make a
|
||||
// version older than the same version without one (4.19.2-rc1 < 4.19.2).
|
||||
func IsNewer(current, latest string) bool {
|
||||
cur := parseVersion(current)
|
||||
lat := parseVersion(latest)
|
||||
for i := 0; i < 3; i++ {
|
||||
if lat.nums[i] != cur.nums[i] {
|
||||
return lat.nums[i] > cur.nums[i]
|
||||
}
|
||||
}
|
||||
// equal numeric parts: a pre-release is older than the release
|
||||
if cur.pre != lat.pre {
|
||||
return lat.pre == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type semv struct {
|
||||
nums [3]int
|
||||
pre string
|
||||
}
|
||||
|
||||
func parseVersion(v string) semv {
|
||||
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||
var out semv
|
||||
if i := strings.IndexAny(v, "-+"); i >= 0 {
|
||||
out.pre = v[i+1:]
|
||||
v = v[:i]
|
||||
}
|
||||
for i, part := range strings.Split(v, ".") {
|
||||
if i > 2 {
|
||||
break
|
||||
}
|
||||
n, _ := strconv.Atoi(part)
|
||||
out.nums[i] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InContainer reports whether the process runs inside a container where
|
||||
// self-updating the binary is meaningless (the image owns the binary).
|
||||
func InContainer() bool {
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat("/run/.containerenv"); err == nil {
|
||||
return true
|
||||
}
|
||||
return os.Getenv("container") != ""
|
||||
}
|
||||
|
||||
// SelfUpdateSupported reports whether this build can self-update in place.
|
||||
func SelfUpdateSupported() (bool, string) {
|
||||
if InContainer() {
|
||||
return false, "container"
|
||||
}
|
||||
if _, err := os.Executable(); err != nil {
|
||||
return false, "no-executable"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// platformAssetName maps the current platform to a release asset name.
|
||||
func platformAssetName(tag string) (string, error) {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "arm" {
|
||||
// GOARM is not exposed at runtime; derive it from uname on Linux.
|
||||
arch = "armv7"
|
||||
if runtime.GOOS == "linux" {
|
||||
if out, err := unameMachine(); err == nil {
|
||||
switch {
|
||||
case strings.HasPrefix(out, "armv5"):
|
||||
arch = "armv5"
|
||||
case strings.HasPrefix(out, "armv6"):
|
||||
arch = "armv6"
|
||||
default:
|
||||
arch = "armv7"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
supported := map[string]bool{
|
||||
"amd64": true, "arm64": true, "loong64": true,
|
||||
"armv5": true, "armv6": true, "armv7": true,
|
||||
}
|
||||
if !supported[arch] {
|
||||
return "", fmt.Errorf("unsupported architecture for self-update: %s", runtime.GOARCH)
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux", "darwin", "freebsd":
|
||||
return fmt.Sprintf("cloudreve_%s_%s_%s.tar.gz", tag, runtime.GOOS, arch), nil
|
||||
case "windows":
|
||||
return fmt.Sprintf("cloudreve_%s_windows_%s.zip", tag, arch), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform for self-update: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package updatecheck
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNewer(t *testing.T) {
|
||||
cases := []struct {
|
||||
current, latest string
|
||||
want bool
|
||||
}{
|
||||
{"4.19.1", "4.19.2", true},
|
||||
{"v4.19.1", "v4.19.2", true},
|
||||
{"4.19.2", "4.19.2", false},
|
||||
{"4.19.2", "4.19.1", false},
|
||||
{"4.19.2", "4.20.0", true},
|
||||
{"4.19.9", "4.20.0", true},
|
||||
{"4.19.9", "5.0.0", true},
|
||||
{"4.19.2-rc1", "4.19.2", true},
|
||||
{"4.19.2", "4.19.2-rc1", false},
|
||||
{"5.0.0", "4.99.9", false},
|
||||
{"4.19", "4.19.1", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsNewer(c.current, c.latest); got != c.want {
|
||||
t.Errorf("IsNewer(%q, %q) = %v, want %v", c.current, c.latest, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/updatecheck"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type (
|
||||
// UpdateCheckService returns the latest GitHub release for this server.
|
||||
UpdateCheckService struct{}
|
||||
UpdateCheckParamCtx struct{}
|
||||
|
||||
// UpdateApplyService performs an in-place self-update and restarts.
|
||||
UpdateApplyService struct{}
|
||||
UpdateApplyParamCtx struct{}
|
||||
|
||||
// UpdateCheckResult combines release info with self-update capability.
|
||||
UpdateCheckResult struct {
|
||||
*updatecheck.ReleaseInfo
|
||||
SelfUpdate bool `json:"self_update"`
|
||||
Container bool `json:"container"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
// Check compares the running version against the newest GitHub release.
|
||||
func (service *UpdateCheckService) Check(c *gin.Context) (*UpdateCheckResult, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
rel, err := updatecheck.LatestRelease(c, dep.KV())
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to check for updates: "+err.Error(), err)
|
||||
}
|
||||
ok, reason := updatecheck.SelfUpdateSupported()
|
||||
return &UpdateCheckResult{
|
||||
ReleaseInfo: rel,
|
||||
SelfUpdate: ok,
|
||||
Container: updatecheck.InContainer(),
|
||||
Reason: reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Apply downloads, verifies and installs the latest release, then restarts the
|
||||
// process. The response is returned before the binary swap starts so the admin
|
||||
// sees a confirmation instead of a dropped connection.
|
||||
func (service *UpdateApplyService) Apply(c *gin.Context) (*updatecheck.ReleaseInfo, error) {
|
||||
if ok, reason := updatecheck.SelfUpdateSupported(); !ok {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Self-update is not supported in this environment: "+reason, nil)
|
||||
}
|
||||
|
||||
dep := dependency.FromContext(c)
|
||||
rel, err := updatecheck.LatestRelease(c, dep.KV())
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to check for updates: "+err.Error(), err)
|
||||
}
|
||||
if !rel.Newer {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Already on the latest version", nil)
|
||||
}
|
||||
|
||||
dep.Logger().Info("Applying server update to v%s", rel.Version)
|
||||
logger := dep.Logger()
|
||||
go func() {
|
||||
// Give the HTTP response a moment to flush before the process exits.
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
if err := updatecheck.ApplyUpdate(ctx, rel); err != nil {
|
||||
logger.Error("Self-update failed: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
Loading…
Reference in new issue