feat(ota): verify offline update checksums

This commit is contained in:
watermeko
2026-08-03 02:56:16 +00:00
committed by Guoguo
parent 7cda3b2920
commit 0f8ef20fb5
32 changed files with 286 additions and 51 deletions

View File

@@ -39,10 +39,16 @@ func (s *Service) Update(c *gin.Context) {
rsp.OkRsp(c) rsp.OkRsp(c)
log.Debugf("update application success") log.Debugf("update application success")
// Sleep for a second before restarting the device go restartServices()
}
func restartServices() {
// Let the HTTP response reach the client before stopping the server.
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
_ = exec.Command("sh", "-c", "/kvmapp/system/init.d/S95nanokvm restart").Run() if err := exec.Command("/kvmapp/system/init.d/S95nanokvm", "restart").Run(); err != nil {
log.Errorf("failed to restart services after update: %v", err)
}
} }
func update() error { func update() error {
@@ -88,7 +94,7 @@ func download(url string, target string) (err error) {
} }
var req *http.Request var req *http.Request
req, err = http.NewRequest("GET", url, nil) req, err = utils.NewAuthenticatedRequest("GET", url, nil)
if err != nil { if err != nil {
log.Errorf("new request err: %s", err) log.Errorf("new request err: %s", err)
continue continue

View File

@@ -1,15 +1,16 @@
package application package application
import ( import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
"NanoKVM-Server/proto" "NanoKVM-Server/proto"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -35,11 +36,15 @@ func (s *Service) OfflineUpdate(c *gin.Context) {
rsp.OkRsp(c) rsp.OkRsp(c)
log.Debugf("offline update application success") log.Debugf("offline update application success")
time.Sleep(1 * time.Second) go restartServices()
_ = exec.Command("sh", "-c", "/kvmapp/system/init.d/S95nanokvm restart").Run()
} }
func offlineUpdate(c *gin.Context) error { func offlineUpdate(c *gin.Context) error {
expectedSHA256, err := parseSHA256Checksum(c.GetHeader("X-SHA256-Checksum"))
if err != nil {
return err
}
_ = os.RemoveAll(CacheDir) _ = os.RemoveAll(CacheDir)
_ = os.MkdirAll(CacheDir, 0o755) _ = os.MkdirAll(CacheDir, 0o755)
defer func() { defer func() {
@@ -63,6 +68,11 @@ func offlineUpdate(c *gin.Context) error {
return err return err
} }
if err := verifySHA256Checksum(target, expectedSHA256); err != nil {
log.Errorf("failed to verify install package: %v", err)
return err
}
if err := installPackage(target); err != nil { if err := installPackage(target); err != nil {
log.Errorf("failed to install package: %v", err) log.Errorf("failed to install package: %v", err)
return err return err
@@ -71,6 +81,43 @@ func offlineUpdate(c *gin.Context) error {
return nil return nil
} }
func parseSHA256Checksum(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
checksum, err := hex.DecodeString(value)
if err != nil || len(checksum) != sha256.Size {
return nil, fmt.Errorf("invalid sha256 checksum")
}
return checksum, nil
}
func verifySHA256Checksum(filePath string, expected []byte) error {
if len(expected) == 0 {
return nil
}
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open uploaded file: %w", err)
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return fmt.Errorf("failed to calculate sha256 checksum: %w", err)
}
if subtle.ConstantTimeCompare(hasher.Sum(nil), expected) != 1 {
return fmt.Errorf("sha256 checksum mismatch")
}
return nil
}
func createSentinelFile() error { func createSentinelFile() error {
file, err := os.OpenFile( file, err := os.OpenFile(
sentinelPath, sentinelPath,

View File

@@ -14,6 +14,7 @@ import (
"time" "time"
"NanoKVM-Server/proto" "NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -32,7 +33,7 @@ const (
) )
var ( var (
latestClient = &http.Client{Timeout: 15 * time.Second} latestClient = utils.NewUpdateHTTPClient(15 * time.Second)
packageNamePattern = regexp.MustCompile(`^nanokvm_[0-9]+\.[0-9]+\.[0-9]+\.tar\.gz$`) packageNamePattern = regexp.MustCompile(`^nanokvm_[0-9]+\.[0-9]+\.[0-9]+\.tar\.gz$`)
versionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) versionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
) )
@@ -84,7 +85,11 @@ func getLatest() (*Latest, error) {
query.Set("now", fmt.Sprintf("%d", time.Now().Unix())) query.Set("now", fmt.Sprintf("%d", time.Now().Unix()))
parsedManifestURL.RawQuery = query.Encode() parsedManifestURL.RawQuery = query.Encode()
resp, err := latestClient.Get(parsedManifestURL.String()) request, err := utils.NewAuthenticatedRequest("GET", parsedManifestURL.String(), nil)
if err != nil {
return nil, err
}
resp, err := latestClient.Do(request)
if err != nil { if err != nil {
log.Debugf("failed to request version from %s", parsedManifestURL.Redacted()) log.Debugf("failed to request version from %s", parsedManifestURL.Redacted())
return nil, errors.New("update server is inaccessible") return nil, errors.New("update server is inaccessible")

View File

@@ -5,8 +5,10 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -14,7 +16,55 @@ import (
const maxDownloadSize = int64(1024 * 1024 * 1024) const maxDownloadSize = int64(1024 * 1024 * 1024)
var downloadClient = &http.Client{Timeout: 15 * time.Minute} var downloadClient = NewUpdateHTTPClient(15 * time.Minute)
func NewAuthenticatedRequest(method string, rawURL string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, rawURL, body)
if err != nil {
return nil, err
}
if req.URL.User != nil {
username := req.URL.User.Username()
password, _ := req.URL.User.Password()
req.SetBasicAuth(username, password)
// Keep credentials out of the request URL after copying them to the header.
req.URL.User = nil
}
return req, nil
}
func NewUpdateHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
CheckRedirect: preserveBasicAuthRedirect,
}
}
func preserveBasicAuthRedirect(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
previous := via[len(via)-1]
authorization := previous.Header.Get("Authorization")
if authorization == "" {
return nil
}
if !sameUpdateHost(previous.URL, req.URL) ||
(previous.URL.Scheme == "https" && req.URL.Scheme != "https") {
return http.ErrUseLastResponse
}
req.Header.Set("Authorization", authorization)
return nil
}
func sameUpdateHost(left *url.URL, right *url.URL) bool {
return strings.EqualFold(left.Host, right.Host)
}
func Download(req *http.Request, target string) error { func Download(req *http.Request, target string) error {
log.Debugf("downloading %s to %s", req.URL.Redacted(), target) log.Debugf("downloading %s to %s", req.URL.Redacted(), target)

View File

@@ -21,11 +21,12 @@ export function update() {
} }
// offline update application // offline update application
export function offlineUpdate(data: FormData) { export function offlineUpdate(data: FormData, sha256Checksum = '') {
const baseUrl = getBaseUrl('http'); const baseUrl = getBaseUrl('http');
const url = `${baseUrl}/api/application/update/offline`; const url = `${baseUrl}/api/application/update/offline`;
return fetch(url, { return fetch(url, {
method: 'POST', method: 'POST',
headers: sha256Checksum ? { 'X-SHA256-Checksum': sha256Checksum } : undefined,
body: data body: data
}); });
} }

View File

@@ -538,6 +538,11 @@ const ca = {
title: 'Actualitzacions fora de línia', title: 'Actualitzacions fora de línia',
desc: "Actualització mitjançant el paquet d'instal·lació local", desc: "Actualització mitjançant el paquet d'instal·lació local",
upload: 'Puja', upload: 'Puja',
checksumPlaceholder: 'Suma de verificació SHA-256 (opcional)',
invalidChecksum:
'La suma de verificació SHA-256 ha de contenir 64 caràcters hexadecimals.',
checksumMismatch:
'La verificació SHA-256 ha fallat. És possible que el paquet estigui malmès.',
invalidName: 'Format de nom de fitxer no vàlid. Baixeu-lo des de les versions de GitHub.', invalidName: 'Format de nom de fitxer no vàlid. Baixeu-lo des de les versions de GitHub.',
updateFailed: 'Error en actualitzar. Torna-ho a intentar.' updateFailed: 'Error en actualitzar. Torna-ho a intentar.'
} }

View File

@@ -542,6 +542,9 @@ const cz = {
title: 'Offline aktualizace', title: 'Offline aktualizace',
desc: 'Aktualizace prostřednictvím místního instalačního balíčku', desc: 'Aktualizace prostřednictvím místního instalačního balíčku',
upload: 'Nahrát', upload: 'Nahrát',
checksumPlaceholder: 'Kontrolní součet SHA-256 (volitelný)',
invalidChecksum: 'Kontrolní součet SHA-256 musí obsahovat 64 hexadecimálních znaků.',
checksumMismatch: 'Ověření SHA-256 se nezdařilo. Balíček může být poškozený.',
invalidName: 'Neplatný formát souboru. Stáhněte si prosím z vydání GitHubu.', invalidName: 'Neplatný formát souboru. Stáhněte si prosím z vydání GitHubu.',
updateFailed: 'Aktualizace se nezdařila. Zkuste to prosím znovu.' updateFailed: 'Aktualizace se nezdařila. Zkuste to prosím znovu.'
} }

View File

@@ -540,6 +540,9 @@ const da = {
title: 'Offline opdateringer', title: 'Offline opdateringer',
desc: 'Opdatering via lokal installationspakke', desc: 'Opdatering via lokal installationspakke',
upload: 'Upload', upload: 'Upload',
checksumPlaceholder: 'SHA-256-kontrolsum (valgfri)',
invalidChecksum: 'SHA-256-kontrolsummen skal indeholde 64 hexadecimale tegn.',
checksumMismatch: 'SHA-256-verificeringen mislykkedes. Pakken kan være beskadiget.',
invalidName: 'Ugyldigt filnavnsformat. Download venligst fra GitHub-udgivelser.', invalidName: 'Ugyldigt filnavnsformat. Download venligst fra GitHub-udgivelser.',
updateFailed: 'Opdatering fejlede. Prøv igen.' updateFailed: 'Opdatering fejlede. Prøv igen.'
} }

View File

@@ -547,6 +547,10 @@ const de = {
title: 'Offline Aktualisierung', title: 'Offline Aktualisierung',
desc: 'Über lokales Installationspaket aktualisieren', desc: 'Über lokales Installationspaket aktualisieren',
upload: 'Hochladen', upload: 'Hochladen',
checksumPlaceholder: 'SHA-256-Prüfsumme (optional)',
invalidChecksum: 'Die SHA-256-Prüfsumme muss 64 hexadezimale Zeichen enthalten.',
checksumMismatch:
'Die SHA-256-Überprüfung ist fehlgeschlagen. Das Paket ist möglicherweise beschädigt.',
invalidName: invalidName:
'Ungültiges Dateinamenformat. Bitte laden Sie von den GitHub-Releases herunter.', 'Ungültiges Dateinamenformat. Bitte laden Sie von den GitHub-Releases herunter.',
updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.' updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.'

View File

@@ -536,6 +536,9 @@ const en = {
title: 'Offline Updates', title: 'Offline Updates',
desc: 'Update through local installation package', desc: 'Update through local installation package',
upload: 'Upload', upload: 'Upload',
checksumPlaceholder: 'SHA-256 checksum (optional)',
invalidChecksum: 'The SHA-256 checksum must contain 64 hexadecimal characters.',
checksumMismatch: 'SHA-256 verification failed. The package may be corrupted.',
invalidName: 'Invalid filename format. Please download from GitHub releases.', invalidName: 'Invalid filename format. Please download from GitHub releases.',
updateFailed: 'Update failed. Please retry.' updateFailed: 'Update failed. Please retry.'
} }

View File

@@ -545,6 +545,11 @@ const es = {
title: 'Actualizaciones sin conexión', title: 'Actualizaciones sin conexión',
desc: 'Actualización a través del paquete de instalación local', desc: 'Actualización a través del paquete de instalación local',
upload: 'Subir', upload: 'Subir',
checksumPlaceholder: 'Suma de comprobación SHA-256 (opcional)',
invalidChecksum:
'La suma de comprobación SHA-256 debe contener 64 caracteres hexadecimales.',
checksumMismatch:
'La verificación SHA-256 ha fallado. Es posible que el paquete esté dañado.',
invalidName: invalidName:
'Formato de nombre de archivo no válido. Descargue desde las versiones de GitHub.', 'Formato de nombre de archivo no válido. Descargue desde las versiones de GitHub.',
updateFailed: 'La actualización falló. Por favor, inténtalo de nuevo.' updateFailed: 'La actualización falló. Por favor, inténtalo de nuevo.'

View File

@@ -547,6 +547,9 @@ const fr = {
title: 'Mises à jour hors ligne', title: 'Mises à jour hors ligne',
desc: "Mise à jour via le package d'installation local", desc: "Mise à jour via le package d'installation local",
upload: 'Téléverser', upload: 'Téléverser',
checksumPlaceholder: 'Somme de contrôle SHA-256 (facultative)',
invalidChecksum: 'La somme de contrôle SHA-256 doit contenir 64 caractères hexadécimaux.',
checksumMismatch: 'La vérification SHA-256 a échoué. Le paquet est peut-être endommagé.',
invalidName: invalidName:
'Format de nom de fichier invalide. Veuillez télécharger à partir des versions de GitHub.', 'Format de nom de fichier invalide. Veuillez télécharger à partir des versions de GitHub.',
updateFailed: 'Mise à jour échouée. Veuillez réessayer.' updateFailed: 'Mise à jour échouée. Veuillez réessayer.'

View File

@@ -544,6 +544,9 @@ const hu = {
title: 'Offline frissítések', title: 'Offline frissítések',
desc: 'Frissítés helyi telepítőcsomaggal', desc: 'Frissítés helyi telepítőcsomaggal',
upload: 'Feltöltés', upload: 'Feltöltés',
checksumPlaceholder: 'SHA-256 ellenőrzőösszeg (opcionális)',
invalidChecksum: 'A SHA-256 ellenőrzőösszegnek 64 hexadecimális karakterből kell állnia.',
checksumMismatch: 'Az SHA-256 ellenőrzése sikertelen. Lehet, hogy a csomag sérült.',
invalidName: 'Érvénytelen fájlnévformátum. Kérjük, töltse le a GitHub kiadásaiból.', invalidName: 'Érvénytelen fájlnévformátum. Kérjük, töltse le a GitHub kiadásaiból.',
updateFailed: 'Frissítés sikertelen. Kérem, próbálja újra.' updateFailed: 'Frissítés sikertelen. Kérem, próbálja újra.'
} }

View File

@@ -541,6 +541,9 @@ const id = {
title: 'Pembaruan Offline', title: 'Pembaruan Offline',
desc: 'Perbarui melalui paket instalasi lokal', desc: 'Perbarui melalui paket instalasi lokal',
upload: 'Mengunggah', upload: 'Mengunggah',
checksumPlaceholder: 'Checksum SHA-256 (opsional)',
invalidChecksum: 'Checksum SHA-256 harus berisi 64 karakter heksadesimal.',
checksumMismatch: 'Verifikasi SHA-256 gagal. Paket mungkin rusak.',
invalidName: 'Format nama file tidak valid. Silakan unduh dari rilis GitHub.', invalidName: 'Format nama file tidak valid. Silakan unduh dari rilis GitHub.',
updateFailed: 'Gagal memperbarui, tolong coba lagi.' updateFailed: 'Gagal memperbarui, tolong coba lagi.'
} }

View File

@@ -546,6 +546,10 @@ const it = {
title: 'Aggiornamenti offline', title: 'Aggiornamenti offline',
desc: 'Aggiornamento tramite pacchetto di installazione locale', desc: 'Aggiornamento tramite pacchetto di installazione locale',
upload: 'Carica', upload: 'Carica',
checksumPlaceholder: 'Checksum SHA-256 (facoltativo)',
invalidChecksum: 'Il checksum SHA-256 deve contenere 64 caratteri esadecimali.',
checksumMismatch:
'La verifica SHA-256 non è riuscita. Il pacchetto potrebbe essere danneggiato.',
invalidName: 'Formato nome file non valido. Si prega di scaricare dalle versioni GitHub.', invalidName: 'Formato nome file non valido. Si prega di scaricare dalle versioni GitHub.',
updateFailed: 'Aggiornamento fallito. Riprova.' updateFailed: 'Aggiornamento fallito. Riprova.'
} }

View File

@@ -544,6 +544,10 @@ const ja = {
title: 'オフラインアップデート', title: 'オフラインアップデート',
desc: 'ローカルインストールパッケージでアップデートする', desc: 'ローカルインストールパッケージでアップデートする',
upload: 'アップロード', upload: 'アップロード',
checksumPlaceholder: 'SHA-256チェックサム任意',
invalidChecksum: 'SHA-256チェックサムは64文字の16進数である必要があります。',
checksumMismatch:
'SHA-256の検証に失敗しました。パッケージが破損している可能性があります。',
invalidName: invalidName:
'ファイル名の形式が正しくありません。GitHub リリースページにアクセスしてインストールパッケージをダウンロードしてください。', 'ファイル名の形式が正しくありません。GitHub リリースページにアクセスしてインストールパッケージをダウンロードしてください。',
updateFailed: 'アップデートに失敗しました。もう一度お試しください。' updateFailed: 'アップデートに失敗しました。もう一度お試しください。'

View File

@@ -535,6 +535,9 @@ const ko = {
title: '오프라인 업데이트', title: '오프라인 업데이트',
desc: '로컬 설치 패키지를 통한 업데이트', desc: '로컬 설치 패키지를 통한 업데이트',
upload: '업로드', upload: '업로드',
checksumPlaceholder: 'SHA-256 체크섬(선택 사항)',
invalidChecksum: 'SHA-256 체크섬은 64개의 16진수 문자를 포함해야 합니다.',
checksumMismatch: 'SHA-256 검증에 실패했습니다. 패키지가 손상되었을 수 있습니다.',
invalidName: '유효하지 않은 파일 이름 형식입니다. GitHub 릴리즈에서 다운로드하세요.', invalidName: '유효하지 않은 파일 이름 형식입니다. GitHub 릴리즈에서 다운로드하세요.',
updateFailed: '업데이트에 실패했습니다. 재시도하세요.' updateFailed: '업데이트에 실패했습니다. 재시도하세요.'
} }

View File

@@ -540,6 +540,9 @@ const nb = {
title: 'Offline oppdateringer', title: 'Offline oppdateringer',
desc: 'Oppdater gjennom lokal installasjonspakke', desc: 'Oppdater gjennom lokal installasjonspakke',
upload: 'Last opp', upload: 'Last opp',
checksumPlaceholder: 'SHA-256-sjekksum (valgfritt)',
invalidChecksum: 'SHA-256-sjekksummen må inneholde 64 heksadesimale tegn.',
checksumMismatch: 'SHA-256-verifiseringen mislyktes. Pakken kan være skadet.',
invalidName: 'Ugyldig filnavnformat. Last ned fra GitHub-utgivelser.', invalidName: 'Ugyldig filnavnformat. Last ned fra GitHub-utgivelser.',
updateFailed: 'En feil oppstod under oppdatering. Vennligst forsøk igjen.' updateFailed: 'En feil oppstod under oppdatering. Vennligst forsøk igjen.'
} }

View File

@@ -546,6 +546,9 @@ const nl = {
title: 'Offline-updates', title: 'Offline-updates',
desc: 'Update via lokaal installatiepakket', desc: 'Update via lokaal installatiepakket',
upload: 'Uploaden', upload: 'Uploaden',
checksumPlaceholder: 'SHA-256-controlesom (optioneel)',
invalidChecksum: 'De SHA-256-controlesom moet 64 hexadecimale tekens bevatten.',
checksumMismatch: 'De SHA-256-verificatie is mislukt. Het pakket is mogelijk beschadigd.',
invalidName: 'Ongeldig bestandsnaamformaat. Download de versie van GitHub-releases.', invalidName: 'Ongeldig bestandsnaamformaat. Download de versie van GitHub-releases.',
updateFailed: 'Update mislukt. Probeer het opnieuw.' updateFailed: 'Update mislukt. Probeer het opnieuw.'
} }

View File

@@ -544,6 +544,9 @@ const pl = {
title: 'Aktualizacje offline', title: 'Aktualizacje offline',
desc: 'Aktualizacja poprzez lokalny pakiet instalacyjny', desc: 'Aktualizacja poprzez lokalny pakiet instalacyjny',
upload: 'Prześlij', upload: 'Prześlij',
checksumPlaceholder: 'Suma kontrolna SHA-256 (opcjonalnie)',
invalidChecksum: 'Suma kontrolna SHA-256 musi zawierać 64 znaki szesnastkowe.',
checksumMismatch: 'Weryfikacja SHA-256 nie powiodła się. Pakiet może być uszkodzony.',
invalidName: 'Nieprawidłowy format nazwy pliku. Proszę pobrać z wydań GitHub.', invalidName: 'Nieprawidłowy format nazwy pliku. Proszę pobrać z wydań GitHub.',
updateFailed: 'Aktualizacja nie powiodła się. Spróbuj ponownie.' updateFailed: 'Aktualizacja nie powiodła się. Spróbuj ponownie.'
} }

View File

@@ -542,6 +542,9 @@ const pt_br = {
title: 'Atualizações off-line', title: 'Atualizações off-line',
desc: 'Atualização através do pacote de instalação local', desc: 'Atualização através do pacote de instalação local',
upload: 'Upload', upload: 'Upload',
checksumPlaceholder: 'Soma de verificação SHA-256 (opcional)',
invalidChecksum: 'A soma de verificação SHA-256 deve conter 64 caracteres hexadecimais.',
checksumMismatch: 'A verificação SHA-256 falhou. O pacote pode estar corrompido.',
invalidName: 'Formato de nome de arquivo inválido. Faça download das versões do GitHub.', invalidName: 'Formato de nome de arquivo inválido. Faça download das versões do GitHub.',
updateFailed: 'Falha na atualização. Por favor, tente novamente.' updateFailed: 'Falha na atualização. Por favor, tente novamente.'
} }

View File

@@ -543,6 +543,10 @@ const ru = {
title: 'Автономные обновления', title: 'Автономные обновления',
desc: 'Обновление через локальный установочный пакет', desc: 'Обновление через локальный установочный пакет',
upload: 'Загрузить', upload: 'Загрузить',
checksumPlaceholder: 'Контрольная сумма SHA-256 (необязательно)',
invalidChecksum:
'Контрольная сумма SHA-256 должна содержать 64 шестнадцатеричных символа.',
checksumMismatch: 'Проверка SHA-256 не пройдена. Возможно, пакет повреждён.',
invalidName: 'Неверный формат имени файла. Загрузите выпуски с GitHub.', invalidName: 'Неверный формат имени файла. Загрузите выпуски с GitHub.',
updateFailed: 'Обновление не удалось. Пожалуйста, попробуйте еще раз.' updateFailed: 'Обновление не удалось. Пожалуйста, попробуйте еще раз.'
} }

View File

@@ -538,6 +538,9 @@ const se = {
title: 'Offlineuppdateringar', title: 'Offlineuppdateringar',
desc: 'Uppdatera genom lokalt installationspaket', desc: 'Uppdatera genom lokalt installationspaket',
upload: 'Ladda upp', upload: 'Ladda upp',
checksumPlaceholder: 'SHA-256-kontrollsumma (valfri)',
invalidChecksum: 'SHA-256-kontrollsumman måste innehålla 64 hexadecimala tecken.',
checksumMismatch: 'SHA-256-verifieringen misslyckades. Paketet kan vara skadat.',
invalidName: 'Ogiltigt filnamnsformat. Ladda ner från GitHub-versioner.', invalidName: 'Ogiltigt filnamnsformat. Ladda ner från GitHub-versioner.',
updateFailed: 'Uppdatering misslyckades. Försök igen.' updateFailed: 'Uppdatering misslyckades. Försök igen.'
} }

View File

@@ -533,6 +533,9 @@ const th = {
title: 'อัปเดตออฟไลน์', title: 'อัปเดตออฟไลน์',
desc: 'อัปเดตผ่านแพ็คเกจการติดตั้งในเครื่อง', desc: 'อัปเดตผ่านแพ็คเกจการติดตั้งในเครื่อง',
upload: 'อัปโหลด', upload: 'อัปโหลด',
checksumPlaceholder: 'ผลรวมตรวจสอบ SHA-256 (ไม่บังคับ)',
invalidChecksum: 'ผลรวมตรวจสอบ SHA-256 ต้องมีอักขระเลขฐานสิบหก 64 ตัว',
checksumMismatch: 'การตรวจสอบ SHA-256 ล้มเหลว แพ็กเกจอาจเสียหาย',
invalidName: 'รูปแบบชื่อไฟล์ไม่ถูกต้อง กรุณาดาวน์โหลดจากรุ่น GitHub', invalidName: 'รูปแบบชื่อไฟล์ไม่ถูกต้อง กรุณาดาวน์โหลดจากรุ่น GitHub',
updateFailed: 'การอัปเดตล้มเหลว กรุณาลองใหม่' updateFailed: 'การอัปเดตล้มเหลว กรุณาลองใหม่'
} }

View File

@@ -542,6 +542,9 @@ const tr = {
title: 'Çevrimdışı Güncellemeler', title: 'Çevrimdışı Güncellemeler',
desc: 'Yerel kurulum paketi aracılığıyla güncelleme', desc: 'Yerel kurulum paketi aracılığıyla güncelleme',
upload: 'Yükle', upload: 'Yükle',
checksumPlaceholder: 'SHA-256 sağlama toplamı (isteğe bağlı)',
invalidChecksum: 'SHA-256 sağlama toplamı 64 onaltılık karakter içermelidir.',
checksumMismatch: 'SHA-256 doğrulaması başarısız oldu. Paket bozulmuş olabilir.',
invalidName: 'Geçersiz dosya adı biçimi. Lütfen GitHub sürümlerinden indirin.', invalidName: 'Geçersiz dosya adı biçimi. Lütfen GitHub sürümlerinden indirin.',
updateFailed: 'Güncelleme başarısız oldu. Lütfen tekrar deneyin.' updateFailed: 'Güncelleme başarısız oldu. Lütfen tekrar deneyin.'
} }

View File

@@ -541,6 +541,9 @@ const uk = {
title: 'Оновлення в автономному режимі', title: 'Оновлення в автономному режимі',
desc: 'Оновлення через локальний інсталяційний пакет', desc: 'Оновлення через локальний інсталяційний пакет',
upload: 'Завантажити', upload: 'Завантажити',
checksumPlaceholder: "Контрольна сума SHA-256 (необов'язково)",
invalidChecksum: 'Контрольна сума SHA-256 має містити 64 шістнадцяткові символи.',
checksumMismatch: 'Не вдалося перевірити SHA-256. Пакунок може бути пошкоджений.',
invalidName: 'Недійсний формат імені файлу. Завантажте випуски з GitHub.', invalidName: 'Недійсний формат імені файлу. Завантажте випуски з GitHub.',
updateFailed: 'Оновлення не вдалося. Будь ласка, спробуйте ще раз.' updateFailed: 'Оновлення не вдалося. Будь ласка, спробуйте ще раз.'
} }

View File

@@ -539,6 +539,9 @@ const vi = {
title: 'Cập nhật ngoại tuyến', title: 'Cập nhật ngoại tuyến',
desc: 'Cập nhật thông qua gói cài đặt cục bộ', desc: 'Cập nhật thông qua gói cài đặt cục bộ',
upload: 'Tải lên', upload: 'Tải lên',
checksumPlaceholder: 'Tổng kiểm SHA-256 (không bắt buộc)',
invalidChecksum: 'Tổng kiểm SHA-256 phải chứa 64 ký tự thập lục phân.',
checksumMismatch: 'Xác minh SHA-256 không thành công. Gói có thể đã bị hỏng.',
invalidName: invalidName:
'Định dạng tên tệp không hợp lệ. Vui lòng tải xuống từ bản phát hành GitHub.', 'Định dạng tên tệp không hợp lệ. Vui lòng tải xuống từ bản phát hành GitHub.',
updateFailed: 'Cập nhật thất bại. Vui lòng thử lại.' updateFailed: 'Cập nhật thất bại. Vui lòng thử lại.'

View File

@@ -525,6 +525,9 @@ const zh = {
title: '离线更新', title: '离线更新',
desc: '通过本地安装包进行更新', desc: '通过本地安装包进行更新',
upload: '上传', upload: '上传',
checksumPlaceholder: 'SHA-256 校验和(可选)',
invalidChecksum: 'SHA-256 校验和必须为 64 位十六进制字符。',
checksumMismatch: 'SHA-256 校验失败,安装包可能已损坏。',
invalidName: '文件名格式错误,请前往 GitHub 发布页下载安装包。', invalidName: '文件名格式错误,请前往 GitHub 发布页下载安装包。',
updateFailed: '更新失败,请重试' updateFailed: '更新失败,请重试'
} }

View File

@@ -525,6 +525,9 @@ const zh_tw = {
title: '離線更新', title: '離線更新',
desc: '透過本地安裝包進行更新', desc: '透過本地安裝包進行更新',
upload: '上傳', upload: '上傳',
checksumPlaceholder: 'SHA-256 校驗和(選填)',
invalidChecksum: 'SHA-256 校驗和必須包含 64 個十六進位字元。',
checksumMismatch: 'SHA-256 驗證失敗。套件可能已損毀。',
invalidName: '檔名格式錯誤,請前往 GitHub 釋出頁下載安裝包。', invalidName: '檔名格式錯誤,請前往 GitHub 釋出頁下載安裝包。',
updateFailed: '更新失敗,請重試' updateFailed: '更新失敗,請重試'
} }

View File

@@ -10,9 +10,14 @@ const OFFICIAL_UPDATE_SERVER = 'https://cdn.sipeed.com/nanokvm';
interface CustomServerProps { interface CustomServerProps {
checkForUpdates: () => void; checkForUpdates: () => void;
onEnabledChange: (enabled: boolean) => void; onEnabledChange: (enabled: boolean) => void;
onPendingChange: (pending: boolean) => void;
} }
export const CustomServer = ({ checkForUpdates, onEnabledChange }: CustomServerProps) => { export const CustomServer = ({
checkForUpdates,
onEnabledChange,
onPendingChange
}: CustomServerProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [savedConfig, setSavedConfig] = useState<api.UpdateServerConfig>({ const [savedConfig, setSavedConfig] = useState<api.UpdateServerConfig>({
@@ -99,7 +104,6 @@ export const CustomServer = ({ checkForUpdates, onEnabledChange }: CustomServerP
function confirmRisk() { function confirmRisk() {
setEnabled(true); setEnabled(true);
setError(''); setError('');
onEnabledChange(true);
setIsConfirmOpen(false); setIsConfirmOpen(false);
} }
@@ -136,6 +140,11 @@ export const CustomServer = ({ checkForUpdates, onEnabledChange }: CustomServerP
} }
const hasChanges = enabled !== savedConfig.enabled || url.trim() !== savedConfig.url; const hasChanges = enabled !== savedConfig.enabled || url.trim() !== savedConfig.url;
useEffect(() => {
onPendingChange(hasChanges);
}, [hasChanges, onPendingChange]);
const modalTitle = ( const modalTitle = (
<div className="flex items-center space-x-1 text-red-500"> <div className="flex items-center space-x-1 text-red-500">
<TriangleAlertIcon size={18} /> <TriangleAlertIcon size={18} />

View File

@@ -22,6 +22,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
const [latestVersion, setLatestVersion] = useState(''); const [latestVersion, setLatestVersion] = useState('');
const [errMsg, setErrMsg] = useState(''); const [errMsg, setErrMsg] = useState('');
const [isCustomServerEnabled, setIsCustomServerEnabled] = useState(false); const [isCustomServerEnabled, setIsCustomServerEnabled] = useState(false);
const [isCustomServerPending, setIsCustomServerPending] = useState(false);
const versionRequestRef = useRef(0); const versionRequestRef = useRef(0);
useEffect(() => { useEffect(() => {
@@ -60,7 +61,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
} }
function update() { function update() {
if (status !== 'outdated') return; if (status !== 'outdated' || isCustomServerPending) return;
setIsLocked(true); setIsLocked(true);
setStatus('updating'); setStatus('updating');
@@ -88,8 +89,15 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
<div className="text-base">{t('settings.update.title')}</div> <div className="text-base">{t('settings.update.title')}</div>
<Divider className="opacity-50" /> <Divider className="opacity-50" />
<Preview checkForUpdates={checkForUpdates} disabled={isCustomServerEnabled} /> <Preview
<CustomServer checkForUpdates={checkForUpdates} onEnabledChange={setIsCustomServerEnabled} /> checkForUpdates={checkForUpdates}
disabled={isCustomServerEnabled || isCustomServerPending}
/>
<CustomServer
checkForUpdates={checkForUpdates}
onEnabledChange={setIsCustomServerEnabled}
onPendingChange={setIsCustomServerPending}
/>
<Offline <Offline
status={status} status={status}
setStatus={setStatus} setStatus={setStatus}
@@ -133,7 +141,12 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
title={`${currentVersion} -> ${latestVersion}`} title={`${currentVersion} -> ${latestVersion}`}
subTitle={t('settings.update.available')} subTitle={t('settings.update.available')}
extra={[ extra={[
<Button key="confirm" type="primary" onClick={update}> <Button
key="confirm"
type="primary"
disabled={isCustomServerPending}
onClick={update}
>
{t('settings.update.confirm')} {t('settings.update.confirm')}
</Button> </Button>
]} ]}

View File

@@ -1,5 +1,5 @@
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { Button } from 'antd'; import { Button, Input } from 'antd';
import { ExternalLinkIcon } from 'lucide-react'; import { ExternalLinkIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -16,6 +16,7 @@ export const Offline = ({ status, setStatus, setIsLocked, setErrMsg }: UpdatePro
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [sha256Checksum, setSha256Checksum] = useState('');
function handleClick() { function handleClick() {
inputRef.current?.click(); inputRef.current?.click();
@@ -26,6 +27,7 @@ export const Offline = ({ status, setStatus, setIsLocked, setErrMsg }: UpdatePro
if (!file) { if (!file) {
return; return;
} }
e.target.value = '';
if (!validateFilename(file.name)) { if (!validateFilename(file.name)) {
setStatus('failed'); setStatus('failed');
@@ -39,6 +41,13 @@ export const Offline = ({ status, setStatus, setIsLocked, setErrMsg }: UpdatePro
function upload(file: File | null) { function upload(file: File | null) {
if (!file) return; if (!file) return;
const checksum = sha256Checksum.trim();
if (checksum && !/^[a-fA-F0-9]{64}$/.test(checksum)) {
setStatus('failed');
setErrMsg(t('settings.update.offline.invalidChecksum'));
return;
}
if (!validateFilename(file.name)) { if (!validateFilename(file.name)) {
setStatus('failed'); setStatus('failed');
setErrMsg(t('settings.update.offline.invalidName')); setErrMsg(t('settings.update.offline.invalidName'));
@@ -57,28 +66,30 @@ export const Offline = ({ status, setStatus, setIsLocked, setErrMsg }: UpdatePro
formData.append('file', file); formData.append('file', file);
api api
.offlineUpdate(formData) .offlineUpdate(formData, checksum)
.then((rsp: Response) => { .then((rsp: Response) => {
// Prüfen ob HTTP OK
if (!rsp.ok) throw new Error(`HTTP error ${rsp.status}`); if (!rsp.ok) throw new Error(`HTTP error ${rsp.status}`);
return rsp.json(); // JSON-Payload parsen return rsp.json();
}) })
.then((rspj: any) => { .then((rspj: any) => {
// Jetzt rspj ist das tatsächliche JSON if (rspj.code !== 0) {
if (rspj.code !== 0 || !rspj.data) { const message = rspj.msg?.includes('sha256 checksum mismatch')
setStatus('failed'); ? t('settings.update.offline.checksumMismatch')
setErrMsg(rspj.msg || t('settings.update.offline.updateFailed')); : rspj.msg || t('settings.update.offline.updateFailed');
console.log(rspj); throw new Error(message);
return;
} }
})
.finally(() => {
setTimeout(() => { setTimeout(() => {
setIsLocked(false); setIsLocked(false);
setErrMsg('');
window.location.reload(); window.location.reload();
}, 12000); }, 12000);
})
.catch((error: unknown) => {
setIsLocked(false);
setStatus('failed');
setErrMsg(
error instanceof Error ? error.message : t('settings.update.offline.updateFailed')
);
}); });
} }
@@ -89,31 +100,44 @@ export const Offline = ({ status, setStatus, setIsLocked, setErrMsg }: UpdatePro
return ( return (
<> <>
<div className="mt-8 flex items-center justify-between"> <div className="mt-8 flex flex-col gap-3">
<div className="flex flex-col space-y-1"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center space-x-2"> <div className="flex flex-col space-y-1">
<span>{t('settings.update.offline.title')}</span> <div className="flex items-center space-x-2">
<span>{t('settings.update.offline.title')}</span>
<a <a
className="flex items-center text-neutral-500 hover:text-blue-500" className="flex items-center text-neutral-500 hover:text-blue-500"
href="https://github.com/sipeed/NanoKVM/releases" href="https://github.com/sipeed/NanoKVM/releases"
target="_blank" target="_blank"
> >
<ExternalLinkIcon size={15} /> <ExternalLinkIcon size={15} />
</a> </a>
</div>
<span className="text-xs text-neutral-500">{t('settings.update.offline.desc')}</span>
</div> </div>
<span className="text-xs text-neutral-500">{t('settings.update.offline.desc')}</span> <input
id="file-upload"
ref={inputRef}
type="file"
accept=".tar.gz"
onChange={handleFileChange}
className="hidden"
/>
<Button disabled={status === 'loading' || status === 'updating'} onClick={handleClick}>
{t('settings.update.offline.upload')}
</Button>
</div> </div>
<input <Input
id="file-upload" value={sha256Checksum}
ref={inputRef} maxLength={64}
type="file" disabled={status === 'updating'}
onChange={handleFileChange} placeholder={t('settings.update.offline.checksumPlaceholder')}
className="hidden" onChange={(event) => setSha256Checksum(event.target.value)}
/> />
<Button onClick={handleClick}>{t('settings.update.offline.upload')}</Button>
</div> </div>
</> </>
); );