feat(update): server self-update + desktop auto-download on prompt

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
Tomas Dvorak 2 weeks ago
parent d18d8dfdc1
commit 164431527e

@ -1557,7 +1557,7 @@ pub fn restart_app(app: AppHandle) {
/// Show or create the in-app update window. The window itself runs the update /// Show or create the in-app update window. The window itself runs the update
/// check so it works both for automatic prompts and manual "Check for /// check so it works both for automatic prompts and manual "Check for
/// updates" from the About page. /// updates" from the About page.
pub fn show_update_window_impl(app: &AppHandle) { pub fn show_update_window_impl(app: &AppHandle, auto_download: bool) {
// One update window at a time — refocus instead of stacking dialogs. // One update window at a time — refocus instead of stacking dialogs.
if let Some(window) = app.get_webview_window("update") { if let Some(window) = app.get_webview_window("update") {
let _ = window.unminimize(); let _ = window.unminimize();
@ -1566,7 +1566,11 @@ pub fn show_update_window_impl(app: &AppHandle) {
return; return;
} }
let url_path = get_url_with_lang("index.html#/update"); let url_path = get_url_with_lang(if auto_download {
"index.html#/update?auto=1"
} else {
"index.html#/update"
});
#[cfg(windows)] #[cfg(windows)]
let effects = WindowEffectsConfig { let effects = WindowEffectsConfig {
@ -1626,6 +1630,6 @@ pub fn show_update_window_impl(app: &AppHandle) {
/// Open the in-app update window (Settings → About → "Check for updates"). /// Open the in-app update window (Settings → About → "Check for updates").
#[tauri::command] #[tauri::command]
pub async fn show_update_window(app: AppHandle) -> CommandResult<()> { pub async fn show_update_window(app: AppHandle) -> CommandResult<()> {
show_update_window_impl(&app); show_update_window_impl(&app, false);
Ok(()) Ok(())
} }

@ -209,7 +209,7 @@ async fn init_sync_service(app: AppHandle) -> anyhow::Result<()> {
} }
let _ = ConfigManager::get() let _ = ConfigManager::get()
.update(|c| c.prompted_update_version = Some(update.version.clone())); .update(|c| c.prompted_update_version = Some(update.version.clone()));
commands::show_update_window_impl(&app); commands::show_update_window_impl(&app, true);
}); });
} }

@ -12,7 +12,7 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DenseFilledTextField } from "../common/StyledComponent"; import { DenseFilledTextField } from "../common/StyledComponent";
@ -34,6 +34,25 @@ export default function Update() {
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const downloadedRef = useRef(0); const downloadedRef = useRef(0);
const auto = new URLSearchParams(window.location.hash.split("?")[1]).get(
"auto"
) === "1";
const startUpdate = useCallback(async () => {
setPhase("downloading");
setError(null);
downloadedRef.current = 0;
setProgress(0);
try {
await invoke("install_update");
// The ready phase is set by the update-finished listener; if the
// event raced past us, still land on ready.
setPhase((p) => (p === "downloading" ? "ready" : p));
} catch (e) {
setError(String(e));
setPhase("error");
}
}, []);
useEffect(() => { useEffect(() => {
const unProgress = listen<{ downloaded: number; total: number | null }>( const unProgress = listen<{ downloaded: number; total: number | null }>(
@ -52,7 +71,12 @@ export default function Update() {
invoke<UpdateInfo | null>("check_update") invoke<UpdateInfo | null>("check_update")
.then((u) => { .then((u) => {
setInfo(u); setInfo(u);
if (u && auto) {
// Opened from the startup prompt — start the download right away.
startUpdate();
} else {
setPhase(u ? "available" : "uptodate"); setPhase(u ? "available" : "uptodate");
}
}) })
.catch((e) => { .catch((e) => {
setError(String(e)); setError(String(e));
@ -63,23 +87,7 @@ export default function Update() {
unProgress.then((f) => f()); unProgress.then((f) => f());
unFinished.then((f) => f()); unFinished.then((f) => f());
}; };
}, []); }, [auto, startUpdate]);
const startUpdate = async () => {
setPhase("downloading");
setError(null);
downloadedRef.current = 0;
setProgress(0);
try {
await invoke("install_update");
// The ready phase is set by the update-finished listener; if the
// event raced past us, still land on ready.
setPhase((p) => (p === "downloading" ? "ready" : p));
} catch (e) {
setError(String(e));
setPhase("error");
}
};
return ( return (
<Box <Box
@ -155,6 +163,14 @@ export default function Update() {
{phase === "downloading" && ( {phase === "downloading" && (
<> <>
{info && (
<Typography variant="body1" fontWeight={600}>
{t("update.available", {
version: info.version,
current: info.current_version,
})}
</Typography>
)}
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("update.downloading")} {t("update.downloading")}
</Typography> </Typography>

@ -1992,5 +1992,15 @@
"dismissed": "رفض البلاغ", "dismissed": "رفض البلاغ",
"open": "إعادة فتح البلاغ" "open": "إعادة فتح البلاغ"
} }
},
"update": {
"available": "الإصدار {{version}} متوفر (الحالي: {{current}})",
"title": "تحديث البرنامج",
"updateNow": "تحديث الآن",
"updating": "جارٍ التنزيل والتثبيت — سيعاد تشغيل الخادم تلقائيًا…",
"viewRelease": "عرض الإصدار على GitHub",
"containerHint": "يعمل هذا الخادم في حاوية. حدّث الصورة: docker compose pull && docker compose up -d",
"unsupported": "التحديث في المكان غير مدعوم في هذه البيئة. نزّل الإصدار يدويًا.",
"timeout": "لم يعد الخادم في الوقت المناسب — تحقق من السجلات وأعد تحميل الصفحة."
} }
} }

@ -2020,5 +2020,15 @@
"learnMore": "Mehr erfahren", "learnMore": "Mehr erfahren",
"promotionTitle": "Sonderrabatt beim Upgrade der Community-Edition", "promotionTitle": "Sonderrabatt beim Upgrade der Community-Edition",
"promotion": "Verwenden Sie beim Kauf den Aktionscode <0>{{code}}</0>, um <1>-{{discount}}%</1> Rabatt zu erhalten." "promotion": "Verwenden Sie beim Kauf den Aktionscode <0>{{code}}</0>, um <1>-{{discount}}%</1> Rabatt zu erhalten."
},
"update": {
"available": "Version {{version}} ist verfügbar (aktuell: {{current}})",
"title": "Software-Update",
"updateNow": "Jetzt aktualisieren",
"updating": "Wird heruntergeladen und installiert — der Server startet automatisch neu…",
"viewRelease": "Release auf GitHub ansehen",
"containerHint": "Dieser Server läuft in einem Container. Aktualisieren Sie das Image: docker compose pull && docker compose up -d",
"unsupported": "Ein direktes Update wird in dieser Umgebung nicht unterstützt. Laden Sie das Release manuell herunter.",
"timeout": "Der Server ist nicht rechtzeitig zurückgekehrt — Logs prüfen und Seite neu laden."
} }
} }

@ -1992,5 +1992,15 @@
"dismissed": "Dismiss report", "dismissed": "Dismiss report",
"open": "Reopen report" "open": "Reopen report"
} }
},
"update": {
"available": "Version {{version}} is available (current: {{current}})",
"title": "Software update",
"updateNow": "Update now",
"updating": "Downloading and installing — the server restarts automatically…",
"viewRelease": "View release on GitHub",
"containerHint": "This server runs in a container. Update the image instead: docker compose pull && docker compose up -d",
"unsupported": "In-place update is not supported in this environment. Download the release manually.",
"timeout": "The server did not come back in time — check the logs and reload."
} }
} }

@ -1994,5 +1994,15 @@
"dismissed": "Descartar informe", "dismissed": "Descartar informe",
"open": "Reabrir informe" "open": "Reabrir informe"
} }
},
"update": {
"available": "La versión {{version}} está disponible (actual: {{current}})",
"title": "Actualización de software",
"updateNow": "Actualizar ahora",
"updating": "Descargando e instalando — el servidor se reinicia automáticamente…",
"viewRelease": "Ver lanzamiento en GitHub",
"containerHint": "Este servidor se ejecuta en un contenedor. Actualiza la imagen: docker compose pull && docker compose up -d",
"unsupported": "La actualización en el lugar no es compatible con este entorno. Descarga la versión manualmente.",
"timeout": "El servidor no volvió a tiempo — revisa los registros y recarga."
} }
} }

@ -1994,5 +1994,15 @@
"dismissed": "Rejeter le signalement", "dismissed": "Rejeter le signalement",
"open": "Rouvrir le signalement" "open": "Rouvrir le signalement"
} }
},
"update": {
"available": "La version {{version}} est disponible (actuelle : {{current}})",
"title": "Mise à jour logicielle",
"updateNow": "Mettre à jour",
"updating": "Téléchargement et installation — le serveur redémarre automatiquement…",
"viewRelease": "Voir la version sur GitHub",
"containerHint": "Ce serveur s'exécute dans un conteneur. Mettez à jour l'image : docker compose pull && docker compose up -d",
"unsupported": "La mise à jour sur place n'est pas prise en charge dans cet environnement. Téléchargez la version manuellement.",
"timeout": "Le serveur n'est pas revenu à temps — consultez les journaux et rechargez."
} }
} }

@ -1994,5 +1994,15 @@
"dismissed": "Ignora segnalazione", "dismissed": "Ignora segnalazione",
"open": "Riapri segnalazione" "open": "Riapri segnalazione"
} }
},
"update": {
"available": "La versione {{version}} è disponibile (attuale: {{current}})",
"title": "Aggiornamento software",
"updateNow": "Aggiorna ora",
"updating": "Download e installazione in corso — il server si riavvia automaticamente…",
"viewRelease": "Vedi la release su GitHub",
"containerHint": "Questo server gira in un container. Aggiorna l'immagine: docker compose pull && docker compose up -d",
"unsupported": "L'aggiornamento sul posto non è supportato in questo ambiente. Scarica la release manualmente.",
"timeout": "Il server non è tornato in tempo — controlla i log e ricarica."
} }
} }

@ -1992,5 +1992,15 @@
"dismissed": "通報を却下", "dismissed": "通報を却下",
"open": "通報を再開" "open": "通報を再開"
} }
},
"update": {
"available": "バージョン {{version}} が利用可能です(現在: {{current}})",
"title": "ソフトウェア更新",
"updateNow": "今すぐ更新",
"updating": "ダウンロードしてインストール中——サーバーは自動的に再起動します…",
"viewRelease": "GitHub でリリースを見る",
"containerHint": "このサーバーはコンテナ内で実行されています。イメージを更新してください: docker compose pull && docker compose up -d",
"unsupported": "この環境ではインプレース更新をサポートしていません。手動でリリースをダウンロードしてください。",
"timeout": "サーバーが時間内に復帰しませんでした——ログを確認してページを再読み込みしてください。"
} }
} }

@ -1994,5 +1994,15 @@
"dismissed": "신고 기각", "dismissed": "신고 기각",
"open": "신고 다시 열기" "open": "신고 다시 열기"
} }
},
"update": {
"available": "{{version}} 버전을 사용할 수 있습니다 (현재: {{current}})",
"title": "소프트웨어 업데이트",
"updateNow": "지금 업데이트",
"updating": "다운로드 및 설치 중 — 서버가 자동으로 재시작됩니다…",
"viewRelease": "GitHub에서 릴리스 보기",
"containerHint": "이 서버는 컨테이너에서 실행 중입니다. 이미지를 업데이트하세요: docker compose pull && docker compose up -d",
"unsupported": "이 환경에서는 제자리 업데이트를 지원하지 않습니다. 수동으로 릴리스를 다운로드하세요.",
"timeout": "서버가 제때 복귀하지 않았습니다 — 로그를 확인하고 페이지를 새로고침하세요."
} }
} }

@ -1992,5 +1992,15 @@
"dismissed": "Odrzuć zgłoszenie", "dismissed": "Odrzuć zgłoszenie",
"open": "Otwórz zgłoszenie ponownie" "open": "Otwórz zgłoszenie ponownie"
} }
},
"update": {
"available": "Dostępna jest wersja {{version}} (obecna: {{current}})",
"title": "Aktualizacja oprogramowania",
"updateNow": "Aktualizuj teraz",
"updating": "Pobieranie i instalowanie — serwer uruchomi się ponownie automatycznie…",
"viewRelease": "Zobacz wydanie na GitHub",
"containerHint": "Ten serwer działa w kontenerze. Zaktualizuj obraz: docker compose pull && docker compose up -d",
"unsupported": "Aktualizacja w miejscu nie jest obsługiwana w tym środowisku. Pobierz wydanie ręcznie.",
"timeout": "Serwer nie wrócił na czas — sprawdź logi i przeładuj stronę."
} }
} }

@ -1994,5 +1994,15 @@
"dismissed": "Descartar denúncia", "dismissed": "Descartar denúncia",
"open": "Reabrir denúncia" "open": "Reabrir denúncia"
} }
},
"update": {
"available": "A versão {{version}} está disponível (atual: {{current}})",
"title": "Atualização de software",
"updateNow": "Atualizar agora",
"updating": "Baixando e instalando — o servidor reinicia automaticamente…",
"viewRelease": "Ver lançamento no GitHub",
"containerHint": "Este servidor roda em um container. Atualize a imagem: docker compose pull && docker compose up -d",
"unsupported": "A atualização no local não é suportada neste ambiente. Baixe a versão manualmente.",
"timeout": "O servidor não voltou a tempo — verifique os logs e recarregue."
} }
} }

@ -1994,5 +1994,15 @@
"dismissed": "Отклонить жалобу", "dismissed": "Отклонить жалобу",
"open": "Открыть жалобу снова" "open": "Открыть жалобу снова"
} }
},
"update": {
"available": "Доступна версия {{version}} (текущая: {{current}})",
"title": "Обновление ПО",
"updateNow": "Обновить сейчас",
"updating": "Загрузка и установка — сервер перезапустится автоматически…",
"viewRelease": "Открыть релиз на GitHub",
"containerHint": "Сервер работает в контейнере. Обновите образ: docker compose pull && docker compose up -d",
"unsupported": "Обновление на месте не поддерживается в этой среде. Скачайте релиз вручную.",
"timeout": "Сервер не вернулся вовремя — проверьте журналы и перезагрузите страницу."
} }
} }

@ -1992,5 +1992,15 @@
"dismissed": "忽略此举报", "dismissed": "忽略此举报",
"open": "重新打开举报" "open": "重新打开举报"
} }
},
"update": {
"available": "新版本 {{version}} 可用(当前版本:{{current}})",
"title": "软件更新",
"updateNow": "立即更新",
"updating": "正在下载并安装——服务器将自动重启……",
"viewRelease": "在 GitHub 上查看发布",
"containerHint": "此服务器运行在容器中,请更新镜像:docker compose pull && docker compose up -d",
"unsupported": "当前环境不支持原地更新,请手动下载新版本。",
"timeout": "服务器未及时恢复——请查看日志并刷新页面。"
} }
} }

@ -1992,5 +1992,15 @@
"dismissed": "忽略此舉報", "dismissed": "忽略此舉報",
"open": "重新開啟舉報" "open": "重新開啟舉報"
} }
},
"update": {
"available": "新版本 {{version}} 可用(目前版本:{{current}})",
"title": "軟體更新",
"updateNow": "立即更新",
"updating": "正在下載並安裝——伺服器將自動重新啟動……",
"viewRelease": "在 GitHub 上檢視發布",
"containerHint": "此伺服器執行於容器中,請更新映像:docker compose pull && docker compose up -d",
"unsupported": "目前環境不支援原地更新,請手動下載新版本。",
"timeout": "伺服器未及時恢復——請查看日誌並重新整理頁面。"
} }
} }

@ -26,6 +26,7 @@ import {
GetSettingService, GetSettingService,
GroupEnt, GroupEnt,
HomepageSummary, HomepageSummary,
UpdateInfo,
ListEntityResponse, ListEntityResponse,
ListFileResponse, ListFileResponse,
ListNodeResponse, ListNodeResponse,
@ -1834,6 +1835,22 @@ export function sendReset(uid: string, req: ResetPasswordService): ThunkResponse
}; };
} }
export function getServerUpdateInfo(): ThunkResponse<UpdateInfo> {
return async (dispatch, _getState) => {
return await dispatch(
send(`/admin/tool/update`, { method: "GET" }, { ...defaultOpts }),
);
};
}
export function applyServerUpdate(): ThunkResponse<UpdateInfo> {
return async (dispatch, _getState) => {
return await dispatch(
send(`/admin/tool/update`, { method: "POST" }, { ...defaultOpts }),
);
};
}
export function getDashboardSummary(generateCharts?: boolean): ThunkResponse<HomepageSummary> { export function getDashboardSummary(generateCharts?: boolean): ThunkResponse<HomepageSummary> {
return async (dispatch, _getState) => { return async (dispatch, _getState) => {
return await dispatch( return await dispatch(

@ -26,6 +26,18 @@ export interface HomepageSummary {
version: Version; version: Version;
} }
export interface UpdateInfo {
version: string;
current: string;
url: string;
notes: string;
published_at: string;
newer: boolean;
self_update: boolean;
container: boolean;
reason?: string;
}
export interface ManualRefreshLicenseService { export interface ManualRefreshLicenseService {
license: string; license: string;
} }

@ -43,6 +43,7 @@ import Telegram from "../../Icons/Telegram.tsx";
import PageContainer from "../../Pages/PageContainer.tsx"; import PageContainer from "../../Pages/PageContainer.tsx";
import PageHeader from "../../Pages/PageHeader.tsx"; import PageHeader from "../../Pages/PageHeader.tsx";
import SiteUrlWarning from "./SiteUrlWarning.tsx"; import SiteUrlWarning from "./SiteUrlWarning.tsx";
import UpdateNotice from "./UpdateNotice.tsx";
import CommentMultiple from "../../Icons/CommentMultiple.tsx"; import CommentMultiple from "../../Icons/CommentMultiple.tsx";
const StyledPaper = styled(Paper)(({ theme }) => ({ const StyledPaper = styled(Paper)(({ theme }) => ({
@ -94,6 +95,7 @@ const Home = () => {
/> />
<Container maxWidth="xl"> <Container maxWidth="xl">
<PageHeader title={t("nav.summary")} /> <PageHeader title={t("nav.summary")} />
<UpdateNotice />
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid alignContent={"stretch"} item xs={12} md={8} lg={9}> <Grid alignContent={"stretch"} item xs={12} md={8} lg={9}>
<StyledPaper> <StyledPaper>
@ -331,7 +333,7 @@ const Home = () => {
<OpenFilled /> <OpenFilled />
</StyledListItemIcon> </StyledListItemIcon>
</ListItemButton> </ListItemButton>
<ListItemButton onClick={() => window.open("https://github.com/cloudreve/cloudreve")}> <ListItemButton onClick={() => window.open("https://github.com/Dvorinka/cloudreve")}>
<ListItemIcon> <ListItemIcon>
<GitHub /> <GitHub />
</ListItemIcon> </ListItemIcon>

@ -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)
}
}
}

@ -754,3 +754,23 @@ func AdminBatchDeleteOAuthClient(c *gin.Context) {
} }
c.JSON(200, serializer.Response{}) c.JSON(200, serializer.Response{})
} }
// AdminCheckUpdate returns the latest GitHub release info for this server.
func AdminCheckUpdate(c *gin.Context) {
service := ParametersFromContext[*admin.UpdateCheckService](c, admin.UpdateCheckParamCtx{})
res, err := service.Check(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
}
// AdminApplyUpdate downloads and installs the latest release, then restarts.
func AdminApplyUpdate(c *gin.Context) {
service := ParametersFromContext[*admin.UpdateApplyService](c, admin.UpdateApplyParamCtx{})
res, err := service.Apply(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
}

@ -1144,6 +1144,17 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
middleware.RequiredScopes(types.ScopeAdminWrite), middleware.RequiredScopes(types.ScopeAdminWrite),
controllers.AdminClearEntityUrlCache, controllers.AdminClearEntityUrlCache,
) )
// Check GitHub releases for a newer server build
tool.GET("update",
controllers.FromQuery[adminsvc.UpdateCheckService](adminsvc.UpdateCheckParamCtx{}),
controllers.AdminCheckUpdate,
)
// Download + install the latest release in place
tool.POST("update",
middleware.RequiredScopes(types.ScopeAdminWrite),
controllers.FromQuery[adminsvc.UpdateApplyService](adminsvc.UpdateApplyParamCtx{}),
controllers.AdminApplyUpdate,
)
} }
queue := admin.Group("queue", middleware.AdminSection(types.GroupPermissionAdminQueue)) queue := admin.Group("queue", middleware.AdminSection(types.GroupPermissionAdminQueue))

@ -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…
Cancel
Save