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