From 164431527e8db8000172bdbd8a0557eea6829a76 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Mon, 21 Sep 2026 16:27:22 +0200 Subject: [PATCH] feat(update): server self-update + desktop auto-download on prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- desktop/src-tauri/src/commands.rs | 10 +- desktop/src-tauri/src/lib.rs | 2 +- desktop/ui/src/pages/Update.tsx | 54 +++-- frontend/public/locales/ar-AR/dashboard.json | 10 + frontend/public/locales/de-DE/dashboard.json | 10 + frontend/public/locales/en-US/dashboard.json | 10 + frontend/public/locales/es-ES/dashboard.json | 10 + frontend/public/locales/fr-FR/dashboard.json | 10 + frontend/public/locales/it-IT/dashboard.json | 10 + frontend/public/locales/ja-JP/dashboard.json | 10 + frontend/public/locales/ko-KR/dashboard.json | 10 + frontend/public/locales/pl-PL/dashboard.json | 10 + frontend/public/locales/pt-BR/dashboard.json | 10 + frontend/public/locales/ru-RU/dashboard.json | 10 + frontend/public/locales/zh-CN/dashboard.json | 10 + frontend/public/locales/zh-TW/dashboard.json | 10 + frontend/src/api/api.ts | 17 ++ frontend/src/api/dashboard.ts | 12 + frontend/src/component/Admin/Home/Home.tsx | 4 +- .../src/component/Admin/Home/UpdateNotice.tsx | 140 +++++++++++ pkg/updatecheck/selfupdate.go | 194 +++++++++++++++ pkg/updatecheck/selfupdate_unix.go | 71 ++++++ pkg/updatecheck/selfupdate_windows.go | 82 +++++++ pkg/updatecheck/updatecheck.go | 224 ++++++++++++++++++ pkg/updatecheck/updatecheck_test.go | 27 +++ routers/controllers/admin.go | 20 ++ routers/router.go | 11 + service/admin/update.go | 77 ++++++ 28 files changed, 1051 insertions(+), 24 deletions(-) create mode 100644 frontend/src/component/Admin/Home/UpdateNotice.tsx create mode 100644 pkg/updatecheck/selfupdate.go create mode 100644 pkg/updatecheck/selfupdate_unix.go create mode 100644 pkg/updatecheck/selfupdate_windows.go create mode 100644 pkg/updatecheck/updatecheck.go create mode 100644 pkg/updatecheck/updatecheck_test.go create mode 100644 service/admin/update.go diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index bb489d6d..62559ca9 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -1557,7 +1557,7 @@ pub fn restart_app(app: AppHandle) { /// 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 /// 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. if let Some(window) = app.get_webview_window("update") { let _ = window.unminimize(); @@ -1566,7 +1566,11 @@ pub fn show_update_window_impl(app: &AppHandle) { 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)] 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"). #[tauri::command] pub async fn show_update_window(app: AppHandle) -> CommandResult<()> { - show_update_window_impl(&app); + show_update_window_impl(&app, false); Ok(()) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5972445e..b5596c2d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -209,7 +209,7 @@ async fn init_sync_service(app: AppHandle) -> anyhow::Result<()> { } let _ = ConfigManager::get() .update(|c| c.prompted_update_version = Some(update.version.clone())); - commands::show_update_window_impl(&app); + commands::show_update_window_impl(&app, true); }); } diff --git a/desktop/ui/src/pages/Update.tsx b/desktop/ui/src/pages/Update.tsx index 76e8e4c9..a7435b17 100644 --- a/desktop/ui/src/pages/Update.tsx +++ b/desktop/ui/src/pages/Update.tsx @@ -12,7 +12,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; 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 { DenseFilledTextField } from "../common/StyledComponent"; @@ -34,6 +34,25 @@ export default function Update() { const [progress, setProgress] = useState(0); const [error, setError] = useState(null); 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(() => { const unProgress = listen<{ downloaded: number; total: number | null }>( @@ -52,7 +71,12 @@ export default function Update() { invoke("check_update") .then((u) => { setInfo(u); - setPhase(u ? "available" : "uptodate"); + if (u && auto) { + // Opened from the startup prompt — start the download right away. + startUpdate(); + } else { + setPhase(u ? "available" : "uptodate"); + } }) .catch((e) => { setError(String(e)); @@ -63,23 +87,7 @@ export default function Update() { unProgress.then((f) => f()); unFinished.then((f) => f()); }; - }, []); - - 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"); - } - }; + }, [auto, startUpdate]); return ( + {info && ( + + {t("update.available", { + version: info.version, + current: info.current_version, + })} + + )} {t("update.downloading")} diff --git a/frontend/public/locales/ar-AR/dashboard.json b/frontend/public/locales/ar-AR/dashboard.json index 28327582..15bca4be 100644 --- a/frontend/public/locales/ar-AR/dashboard.json +++ b/frontend/public/locales/ar-AR/dashboard.json @@ -1992,5 +1992,15 @@ "dismissed": "رفض البلاغ", "open": "إعادة فتح البلاغ" } + }, + "update": { + "available": "الإصدار {{version}} متوفر (الحالي: {{current}})", + "title": "تحديث البرنامج", + "updateNow": "تحديث الآن", + "updating": "جارٍ التنزيل والتثبيت — سيعاد تشغيل الخادم تلقائيًا…", + "viewRelease": "عرض الإصدار على GitHub", + "containerHint": "يعمل هذا الخادم في حاوية. حدّث الصورة: docker compose pull && docker compose up -d", + "unsupported": "التحديث في المكان غير مدعوم في هذه البيئة. نزّل الإصدار يدويًا.", + "timeout": "لم يعد الخادم في الوقت المناسب — تحقق من السجلات وأعد تحميل الصفحة." } } \ No newline at end of file diff --git a/frontend/public/locales/de-DE/dashboard.json b/frontend/public/locales/de-DE/dashboard.json index 20d6d68f..330fc42e 100644 --- a/frontend/public/locales/de-DE/dashboard.json +++ b/frontend/public/locales/de-DE/dashboard.json @@ -2020,5 +2020,15 @@ "learnMore": "Mehr erfahren", "promotionTitle": "Sonderrabatt beim Upgrade der Community-Edition", "promotion": "Verwenden Sie beim Kauf den Aktionscode <0>{{code}}, um <1>-{{discount}}% 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." } } \ No newline at end of file diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index a2ea1e6e..4f527005 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -1992,5 +1992,15 @@ "dismissed": "Dismiss 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." } } \ No newline at end of file diff --git a/frontend/public/locales/es-ES/dashboard.json b/frontend/public/locales/es-ES/dashboard.json index 931b09a9..5d4c4805 100644 --- a/frontend/public/locales/es-ES/dashboard.json +++ b/frontend/public/locales/es-ES/dashboard.json @@ -1994,5 +1994,15 @@ "dismissed": "Descartar 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." } } \ No newline at end of file diff --git a/frontend/public/locales/fr-FR/dashboard.json b/frontend/public/locales/fr-FR/dashboard.json index 707c4b69..1861ee0f 100644 --- a/frontend/public/locales/fr-FR/dashboard.json +++ b/frontend/public/locales/fr-FR/dashboard.json @@ -1994,5 +1994,15 @@ "dismissed": "Rejeter 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." } } \ No newline at end of file diff --git a/frontend/public/locales/it-IT/dashboard.json b/frontend/public/locales/it-IT/dashboard.json index 53137657..2f62af71 100644 --- a/frontend/public/locales/it-IT/dashboard.json +++ b/frontend/public/locales/it-IT/dashboard.json @@ -1994,5 +1994,15 @@ "dismissed": "Ignora 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." } } \ No newline at end of file diff --git a/frontend/public/locales/ja-JP/dashboard.json b/frontend/public/locales/ja-JP/dashboard.json index e901cd38..d7bde720 100644 --- a/frontend/public/locales/ja-JP/dashboard.json +++ b/frontend/public/locales/ja-JP/dashboard.json @@ -1992,5 +1992,15 @@ "dismissed": "通報を却下", "open": "通報を再開" } + }, + "update": { + "available": "バージョン {{version}} が利用可能です(現在: {{current}})", + "title": "ソフトウェア更新", + "updateNow": "今すぐ更新", + "updating": "ダウンロードしてインストール中——サーバーは自動的に再起動します…", + "viewRelease": "GitHub でリリースを見る", + "containerHint": "このサーバーはコンテナ内で実行されています。イメージを更新してください: docker compose pull && docker compose up -d", + "unsupported": "この環境ではインプレース更新をサポートしていません。手動でリリースをダウンロードしてください。", + "timeout": "サーバーが時間内に復帰しませんでした——ログを確認してページを再読み込みしてください。" } } \ No newline at end of file diff --git a/frontend/public/locales/ko-KR/dashboard.json b/frontend/public/locales/ko-KR/dashboard.json index 3bafb893..50826651 100644 --- a/frontend/public/locales/ko-KR/dashboard.json +++ b/frontend/public/locales/ko-KR/dashboard.json @@ -1994,5 +1994,15 @@ "dismissed": "신고 기각", "open": "신고 다시 열기" } + }, + "update": { + "available": "{{version}} 버전을 사용할 수 있습니다 (현재: {{current}})", + "title": "소프트웨어 업데이트", + "updateNow": "지금 업데이트", + "updating": "다운로드 및 설치 중 — 서버가 자동으로 재시작됩니다…", + "viewRelease": "GitHub에서 릴리스 보기", + "containerHint": "이 서버는 컨테이너에서 실행 중입니다. 이미지를 업데이트하세요: docker compose pull && docker compose up -d", + "unsupported": "이 환경에서는 제자리 업데이트를 지원하지 않습니다. 수동으로 릴리스를 다운로드하세요.", + "timeout": "서버가 제때 복귀하지 않았습니다 — 로그를 확인하고 페이지를 새로고침하세요." } } \ No newline at end of file diff --git a/frontend/public/locales/pl-PL/dashboard.json b/frontend/public/locales/pl-PL/dashboard.json index e8eb30a8..d85ea26b 100644 --- a/frontend/public/locales/pl-PL/dashboard.json +++ b/frontend/public/locales/pl-PL/dashboard.json @@ -1992,5 +1992,15 @@ "dismissed": "Odrzuć zgłoszenie", "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ę." } } \ No newline at end of file diff --git a/frontend/public/locales/pt-BR/dashboard.json b/frontend/public/locales/pt-BR/dashboard.json index 98d9126a..02afd350 100644 --- a/frontend/public/locales/pt-BR/dashboard.json +++ b/frontend/public/locales/pt-BR/dashboard.json @@ -1994,5 +1994,15 @@ "dismissed": "Descartar 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." } } \ No newline at end of file diff --git a/frontend/public/locales/ru-RU/dashboard.json b/frontend/public/locales/ru-RU/dashboard.json index 83d247fc..0f6e90fb 100644 --- a/frontend/public/locales/ru-RU/dashboard.json +++ b/frontend/public/locales/ru-RU/dashboard.json @@ -1994,5 +1994,15 @@ "dismissed": "Отклонить жалобу", "open": "Открыть жалобу снова" } + }, + "update": { + "available": "Доступна версия {{version}} (текущая: {{current}})", + "title": "Обновление ПО", + "updateNow": "Обновить сейчас", + "updating": "Загрузка и установка — сервер перезапустится автоматически…", + "viewRelease": "Открыть релиз на GitHub", + "containerHint": "Сервер работает в контейнере. Обновите образ: docker compose pull && docker compose up -d", + "unsupported": "Обновление на месте не поддерживается в этой среде. Скачайте релиз вручную.", + "timeout": "Сервер не вернулся вовремя — проверьте журналы и перезагрузите страницу." } } \ No newline at end of file diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 369c6ba7..2cdc8c52 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -1992,5 +1992,15 @@ "dismissed": "忽略此举报", "open": "重新打开举报" } + }, + "update": { + "available": "新版本 {{version}} 可用(当前版本:{{current}})", + "title": "软件更新", + "updateNow": "立即更新", + "updating": "正在下载并安装——服务器将自动重启……", + "viewRelease": "在 GitHub 上查看发布", + "containerHint": "此服务器运行在容器中,请更新镜像:docker compose pull && docker compose up -d", + "unsupported": "当前环境不支持原地更新,请手动下载新版本。", + "timeout": "服务器未及时恢复——请查看日志并刷新页面。" } } \ No newline at end of file diff --git a/frontend/public/locales/zh-TW/dashboard.json b/frontend/public/locales/zh-TW/dashboard.json index 0ae32c4f..1c4fc09a 100644 --- a/frontend/public/locales/zh-TW/dashboard.json +++ b/frontend/public/locales/zh-TW/dashboard.json @@ -1992,5 +1992,15 @@ "dismissed": "忽略此舉報", "open": "重新開啟舉報" } + }, + "update": { + "available": "新版本 {{version}} 可用(目前版本:{{current}})", + "title": "軟體更新", + "updateNow": "立即更新", + "updating": "正在下載並安裝——伺服器將自動重新啟動……", + "viewRelease": "在 GitHub 上檢視發布", + "containerHint": "此伺服器執行於容器中,請更新映像:docker compose pull && docker compose up -d", + "unsupported": "目前環境不支援原地更新,請手動下載新版本。", + "timeout": "伺服器未及時恢復——請查看日誌並重新整理頁面。" } } \ No newline at end of file diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index c082d4fc..90209c98 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -26,6 +26,7 @@ import { GetSettingService, GroupEnt, HomepageSummary, + UpdateInfo, ListEntityResponse, ListFileResponse, ListNodeResponse, @@ -1834,6 +1835,22 @@ export function sendReset(uid: string, req: ResetPasswordService): ThunkResponse }; } +export function getServerUpdateInfo(): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send(`/admin/tool/update`, { method: "GET" }, { ...defaultOpts }), + ); + }; +} + +export function applyServerUpdate(): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send(`/admin/tool/update`, { method: "POST" }, { ...defaultOpts }), + ); + }; +} + export function getDashboardSummary(generateCharts?: boolean): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 894aa3d4..c48cfa14 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -26,6 +26,18 @@ export interface HomepageSummary { 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 { license: string; } diff --git a/frontend/src/component/Admin/Home/Home.tsx b/frontend/src/component/Admin/Home/Home.tsx index 06687659..cf5b19a5 100644 --- a/frontend/src/component/Admin/Home/Home.tsx +++ b/frontend/src/component/Admin/Home/Home.tsx @@ -43,6 +43,7 @@ import Telegram from "../../Icons/Telegram.tsx"; import PageContainer from "../../Pages/PageContainer.tsx"; import PageHeader from "../../Pages/PageHeader.tsx"; import SiteUrlWarning from "./SiteUrlWarning.tsx"; +import UpdateNotice from "./UpdateNotice.tsx"; import CommentMultiple from "../../Icons/CommentMultiple.tsx"; const StyledPaper = styled(Paper)(({ theme }) => ({ @@ -94,6 +95,7 @@ const Home = () => { /> + @@ -331,7 +333,7 @@ const Home = () => { - window.open("https://github.com/cloudreve/cloudreve")}> + window.open("https://github.com/Dvorinka/cloudreve")}> diff --git a/frontend/src/component/Admin/Home/UpdateNotice.tsx b/frontend/src/component/Admin/Home/UpdateNotice.tsx new file mode 100644 index 00000000..be9a56ea --- /dev/null +++ b/frontend/src/component/Admin/Home/UpdateNotice.tsx @@ -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(); + const [open, setOpen] = useState(false); + const [updating, setUpdating] = useState(false); + const [applyError, setApplyError] = useState(); + + 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 ( + <> + setOpen(true)}> + {t("update.title")} + + } + > + {t("update.available", { version: info.version, current: info.current })} + + + setOpen(false), fullWidth: true, maxWidth: "sm" }} + > + + + {t("update.available", { version: info.version, current: info.current })} + + {info.notes && ( + + )} + {updating && ( + + {t("update.updating")} + + )} + {applyError && ( + + {applyError} + + )} + {info.container && ( + + {t("update.containerHint")} + + )} + {!info.self_update && !info.container && ( + + {t("update.unsupported")} + + )} + + + + + {info.self_update && ( + + {updating ? t("update.updating") : t("update.updateNow")} + + )} + + + + ); +}; + +export default UpdateNotice; diff --git a/pkg/updatecheck/selfupdate.go b/pkg/updatecheck/selfupdate.go new file mode 100644 index 00000000..df2cce15 --- /dev/null +++ b/pkg/updatecheck/selfupdate.go @@ -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: " " + 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 +} diff --git a/pkg/updatecheck/selfupdate_unix.go b/pkg/updatecheck/selfupdate_unix.go new file mode 100644 index 00000000..425d8c2f --- /dev/null +++ b/pkg/updatecheck/selfupdate_unix.go @@ -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 +} diff --git a/pkg/updatecheck/selfupdate_windows.go b/pkg/updatecheck/selfupdate_windows.go new file mode 100644 index 00000000..4a1864ac --- /dev/null +++ b/pkg/updatecheck/selfupdate_windows.go @@ -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") +} diff --git a/pkg/updatecheck/updatecheck.go b/pkg/updatecheck/updatecheck.go new file mode 100644 index 00000000..66480e1a --- /dev/null +++ b/pkg/updatecheck/updatecheck.go @@ -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) + } +} diff --git a/pkg/updatecheck/updatecheck_test.go b/pkg/updatecheck/updatecheck_test.go new file mode 100644 index 00000000..807668ef --- /dev/null +++ b/pkg/updatecheck/updatecheck_test.go @@ -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) + } + } +} diff --git a/routers/controllers/admin.go b/routers/controllers/admin.go index c0f572b4..1cfdaeb5 100644 --- a/routers/controllers/admin.go +++ b/routers/controllers/admin.go @@ -754,3 +754,23 @@ func AdminBatchDeleteOAuthClient(c *gin.Context) { } 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}) +} diff --git a/routers/router.go b/routers/router.go index f0bef26b..c741d641 100644 --- a/routers/router.go +++ b/routers/router.go @@ -1144,6 +1144,17 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { middleware.RequiredScopes(types.ScopeAdminWrite), 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)) diff --git a/service/admin/update.go b/service/admin/update.go new file mode 100644 index 00000000..405c607c --- /dev/null +++ b/service/admin/update.go @@ -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 +}