diff --git a/server/router/application.go b/server/router/application.go index 522e391..9ff7ace 100644 --- a/server/router/application.go +++ b/server/router/application.go @@ -11,10 +11,9 @@ func applicationRouter(r *gin.Engine) { service := application.NewService() api := r.Group("/api").Use(middleware.CheckToken()) - api.GET("/application/version", service.GetVersion) // get application version - api.GET("/application/currentversion", service.GetCurrentVersion) // get application current version - api.POST("/application/update", service.Update) // update application - api.POST("/application/uploadupdate", service.UploadUpdate) // upload update application + api.GET("/application/version", service.GetVersion) // get application version + api.POST("/application/update", service.Update) // update application + api.POST("/application/update/offline", service.OfflineUpdate) // update application offline api.GET("/application/preview", service.GetPreview) // get preview updates state api.POST("/application/preview", service.SetPreview) // set preview updates state diff --git a/server/service/application/install.go b/server/service/application/install.go new file mode 100644 index 0000000..79a3013 --- /dev/null +++ b/server/service/application/install.go @@ -0,0 +1,76 @@ +package application + +import ( + "fmt" + "os" + "sync" + + "NanoKVM-Server/utils" + log "github.com/sirupsen/logrus" +) + +var ( + mutex sync.Mutex + isUpdating bool +) + +func acquireUpdateLock() bool { + mutex.Lock() + defer mutex.Unlock() + + if isUpdating { + return false + } + isUpdating = true + return true +} + +func releaseUpdateLock() { + mutex.Lock() + defer mutex.Unlock() + isUpdating = false +} + +func installPackage(source string) error { + dir, err := utils.UnTarGz(source, CacheDir) + if err != nil { + return fmt.Errorf("failed to decompress app: %w", err) + } + + if err := backupCurrentApp(); err != nil { + return err + } + + if err := applyUpdate(dir); err != nil { + return err + } + + if err := utils.ChmodRecursively(AppDir, 0o755); err != nil { + return fmt.Errorf("failed to chmod: %w", err) + } + + return nil +} + +func backupCurrentApp() error { + if err := os.RemoveAll(BackupDir); err != nil { + return fmt.Errorf("failed to remove backup: %w", err) + } + + if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil { + return fmt.Errorf("failed to backup app: %w", err) + } + + return nil +} + +func applyUpdate(sourceDir string) error { + if err := utils.MoveFilesRecursively(sourceDir, AppDir); err != nil { + // Try to restore backup on failure + if restoreErr := utils.MoveFilesRecursively(BackupDir, AppDir); restoreErr != nil { + log.Errorf("Failed to restore backup after update failure: %v", restoreErr) + } + return fmt.Errorf("failed to move update in place: %w", err) + } + return nil +} diff --git a/server/service/application/progress_writer.go b/server/service/application/progress_writer.go new file mode 100644 index 0000000..79bc391 --- /dev/null +++ b/server/service/application/progress_writer.go @@ -0,0 +1,84 @@ +package application + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + sentinelPath = "/tmp/.download_in_progress" + tickerInterval = 2500 * time.Millisecond + sentinelPermission = 0644 +) + +// progressWriter wraps an io.Writer to track and report upload progress. +type progressWriter struct { + writer io.Writer + written int64 + totalSize int64 + ticker *time.Ticker + done chan struct{} +} + +func newProgressWriter(w io.Writer, totalSize int64) *progressWriter { + pw := &progressWriter{ + writer: w, + totalSize: totalSize, + ticker: time.NewTicker(tickerInterval), + done: make(chan struct{}), + } + + go pw.reportProgress() + return pw +} + +func (pw *progressWriter) reportProgress() { + for { + select { + case <-pw.done: + return + case <-pw.ticker.C: + pw.updateSentinel() + } + } +} + +func (pw *progressWriter) updateSentinel() { + if pw.totalSize == 0 { + return + } + + content, err := os.ReadFile(sentinelPath) + if err != nil { + log.Error("Failed to read sentinel file") + return + } + + parts := strings.SplitN(string(content), ";", 2) + if len(parts) == 0 { + return + } + + percentage := float64(pw.written) / float64(pw.totalSize) * 100 + newContent := fmt.Sprintf("%s;%.2f%%", parts[0], percentage) + + if err := os.WriteFile(sentinelPath, []byte(newContent), sentinelPermission); err != nil { + log.Error("Failed to update sentinel file") + } +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + n, err := pw.writer.Write(p) + pw.written += int64(n) + return n, err +} + +func (pw *progressWriter) Stop() { + pw.ticker.Stop() + close(pw.done) +} diff --git a/server/service/application/update.go b/server/service/application/update.go index 0b6b0c9..065649c 100644 --- a/server/service/application/update.go +++ b/server/service/application/update.go @@ -8,11 +8,7 @@ import ( "net/http" "os" "os/exec" - "sync" "time" - "path/filepath" - "strings" - "regexp" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" @@ -21,235 +17,18 @@ import ( "NanoKVM-Server/utils" ) -var sentinelPath = "/tmp/.download_in_progress" - const ( maxTries = 3 ) -var ( - updateMutex sync.Mutex - isUpdating bool -) - -func (s *Service) UploadUpdate(c *gin.Context) { - var rsp proto.Response - - updateMutex.Lock() - if isUpdating { - updateMutex.Unlock() - rsp.ErrRsp(c, -1, "update already in progress") - return - } - isUpdating = true - updateMutex.Unlock() - - defer func() { - updateMutex.Lock() - isUpdating = false - updateMutex.Unlock() - }() - - if err := uploadupdate(rsp, c); err != nil { - rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err)) - return - } - - rsp.OkRsp(c) - log.Debugf("update application success") - - // Sleep for a second before restarting the device - time.Sleep(1 * time.Second) - - _ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run() -} - -func uploadupdate(rsp proto.Response, c *gin.Context) error { - _ = os.RemoveAll(CacheDir) - _ = os.MkdirAll(CacheDir, 0o755) - defer func() { - _ = os.RemoveAll(CacheDir) - }() - - // Set a sentinel file to mark that there is a download in progress - // This is to prevent multiple downloads at the same time - if _, err := os.Stat(sentinelPath); err == nil { - log.Debug("Download in progress") - rsp.ErrRsp(c, -1, "download in progress") - return err - } - - // Create the sentinel file - err := os.WriteFile(sentinelPath, []byte("start"), 0644) - if err != nil { - log.Error("Failed to create sentinel file") - rsp.ErrRsp(c, -1, "failed to create sentinel file") - return err - } - - // Multipart Reader direkt nutzen (keine FormFile!) - reader, err := c.Request.MultipartReader() - if err != nil { - log.Error("invalid multipart data") - rsp.ErrRsp(c, -1, "invalid multipart data") - defer os.Remove(sentinelPath) - return err - } - - - var lw *loggingWriter - var outPath = "" - - for { - part, err := reader.NextPart() - if err == io.EOF { - break - } - if err != nil { - lw.stopTicker() - defer os.Remove(sentinelPath) - return err - } - - if part.FormName() != "file" { - continue - } - - filename := part.FileName() - if filename == "" { - lw.stopTicker() - defer os.Remove(sentinelPath) - return fmt.Errorf("no filename") - } - - filename = filepath.Base(filename) - - if filename != part.FileName() { - defer os.Remove(sentinelPath) - return fmt.Errorf("path detected in filename") - } - - if strings.Contains(filename, "..") { - log.Warn("path traversal attempt") - rsp.ErrRsp(c, -1, "invalid filename") - defer os.Remove(sentinelPath) - return fmt.Errorf("path traversal attempt") - } - - valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) - if !valid.MatchString(filename) { - rsp.ErrRsp(c, -1, "invalid filename") - defer os.Remove(sentinelPath) - return fmt.Errorf("err4") - } - - data, err := os.ReadFile(sentinelPath) - if err != nil { - lw.stopTicker() - defer os.Remove(sentinelPath) - return err - } - - outPath = "/data/" + filename - out, err := os.Create(outPath) - if err != nil { - lw.stopTicker() - defer os.Remove(sentinelPath) - return err - } - defer out.Close() - - if strings.Contains(string(data), "start") { - err = os.WriteFile(sentinelPath, []byte(filename), 0644) - if err != nil { - lw.stopTicker() - defer os.Remove(outPath) - defer os.Remove(sentinelPath) - return err - } - - lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength} - lw.startTicker() - } else { - if !strings.Contains(string(data), filename) { - lw.stopTicker() - defer os.Remove(outPath) - defer os.Remove(sentinelPath) - return fmt.Errorf("failed") - } - } - - // Direkt streamen → kein RAM-Bedarf außer kleinem Buffer - _, err = io.Copy(lw, part) - if err != nil { - lw.stopTicker() - defer os.Remove(outPath) - defer os.Remove(sentinelPath) - return err - } - } - lw.stopTicker() - - rsp.OkRspWithData(c, &proto.StatusImageRsp{ - Status: "idle", - File: "", - Percentage: "", - }) - - defer os.Remove(sentinelPath) - - // decompress - dir, err := utils.UnTarGz(outPath, CacheDir) - if err != nil { - fmt.Errorf("decompress app failed: %s", err) - return err - } - - // backup old version - if err := os.RemoveAll(BackupDir); err != nil { - fmt.Errorf("remove backup failed: %s", err) - return err - } - - if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil { - fmt.Errorf("backup app failed: %s", err) - return err - } - - // update - if err := utils.MoveFilesRecursively(dir, AppDir); err != nil { - fmt.Errorf("failed to move update back in place: %s", err) - return err - } - - // modify permissions - if err := utils.ChmodRecursively(AppDir, 0o755); err != nil { - fmt.Errorf("chmod failed: %s", err) - return err - } - - defer os.Remove(outPath) - - return nil -} - func (s *Service) Update(c *gin.Context) { var rsp proto.Response - updateMutex.Lock() - if isUpdating { - updateMutex.Unlock() + if !acquireUpdateLock() { rsp.ErrRsp(c, -1, "update already in progress") return } - isUpdating = true - updateMutex.Unlock() - - defer func() { - updateMutex.Lock() - isUpdating = false - updateMutex.Unlock() - }() + defer releaseUpdateLock() if err := update(); err != nil { rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err)) @@ -280,7 +59,6 @@ func update() error { // download target := fmt.Sprintf("%s/%s", CacheDir, latest.Name) - if err := download(latest.Url, target); err != nil { log.Errorf("download app failed: %s", err) return err @@ -292,34 +70,9 @@ func update() error { return err } - // decompress - dir, err := utils.UnTarGz(target, CacheDir) - log.Debugf("untar: %s", dir) - if err != nil { - log.Errorf("decompress app failed: %s", err) - return err - } - - // backup old version - if err := os.RemoveAll(BackupDir); err != nil { - log.Errorf("remove backup failed: %s", err) - return err - } - - if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil { - log.Errorf("backup app failed: %s", err) - return err - } - - // update - if err := utils.MoveFilesRecursively(dir, AppDir); err != nil { - log.Errorf("failed to move update back in place: %s", err) - return err - } - - // modify permissions - if err := utils.ChmodRecursively(AppDir, 0o755); err != nil { - log.Errorf("chmod failed: %s", err) + // install + if err := installPackage(target); err != nil { + log.Errorf("failed to install package: %v", err) return err } @@ -330,7 +83,7 @@ func download(url string, target string) (err error) { for i := range maxTries { log.Debugf("attempt #%d/%d", i+1, maxTries) if i > 0 { - time.Sleep(time.Second * 3) // wait for 3 seconds before retrying the download attempt + time.Sleep(time.Second * 3) } var req *http.Request @@ -378,54 +131,3 @@ func checksum(filePath string, expectedHash string) error { return nil } - -type loggingWriter struct { - writer io.Writer - total int64 - totalSize int64 - ticker *time.Ticker - done chan bool -} - -func (lw *loggingWriter) startTicker() { - lw.ticker = time.NewTicker(2500 * time.Millisecond) - lw.done = make(chan bool) - go func() { - for { - select { - case <-lw.done: - return - case <-lw.ticker.C: - lw.updateSentinel() - } - } - }() -} - -func (lw *loggingWriter) stopTicker() { - lw.ticker.Stop() - lw.done <- true -} - -func (lw *loggingWriter) updateSentinel() { - percentage := float64(lw.total) / float64(lw.totalSize) * 100 - content, err := os.ReadFile(sentinelPath) - if err != nil { - log.Error("Failed to read sentinel file") - return - } - splitted := strings.Split(string(content), ";") - if len(splitted) == 0 { - return - } - err = os.WriteFile(sentinelPath, []byte(fmt.Sprintf("%s;%.2f%%", splitted[0], percentage)), 0644) - if err != nil { - log.Error("Failed to update sentinel file") - } -} - -func (lw *loggingWriter) Write(p []byte) (int, error) { - n, err := lw.writer.Write(p) - lw.total += int64(n) - return n, err -} diff --git a/server/service/application/update_offline.go b/server/service/application/update_offline.go new file mode 100644 index 0000000..8e52d52 --- /dev/null +++ b/server/service/application/update_offline.go @@ -0,0 +1,176 @@ +package application + +import ( + "fmt" + "io" + "mime/multipart" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "NanoKVM-Server/proto" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +var validFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + +func (s *Service) OfflineUpdate(c *gin.Context) { + var rsp proto.Response + + if !acquireUpdateLock() { + rsp.ErrRsp(c, -1, "update already in progress") + return + } + defer releaseUpdateLock() + + if err := offlineUpdate(c); err != nil { + rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err)) + return + } + + rsp.OkRsp(c) + log.Debugf("offline update application success") + + time.Sleep(1 * time.Second) + _ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run() +} + +func offlineUpdate(c *gin.Context) error { + _ = os.RemoveAll(CacheDir) + _ = os.MkdirAll(CacheDir, 0o755) + defer func() { + _ = os.RemoveAll(CacheDir) + }() + + if err := checkDownloadInProgress(); err != nil { + return err + } + + if err := createSentinelFile(); err != nil { + return err + } + defer removeSentinelFile() + + reader, err := c.Request.MultipartReader() + if err != nil { + log.Errorf("Invalid multipart data: %v", err) + return fmt.Errorf("invalid multipart data: %w", err) + } + + target, err := processUpload(reader, c.Request.ContentLength) + if err != nil { + log.Errorf("failed to upload install package: %v", err) + return err + } + + if err := installPackage(target); err != nil { + log.Errorf("failed to install package: %v", err) + return err + } + + return nil +} + +func checkDownloadInProgress() error { + if _, err := os.Stat(sentinelPath); err == nil { + log.Debug("Download in progress") + return fmt.Errorf("download already in progress") + } + return nil +} + +func createSentinelFile() error { + if err := os.WriteFile(sentinelPath, []byte("downloading"), sentinelPermission); err != nil { + log.Errorf("Failed to create sentinel file: %v", err) + return fmt.Errorf("failed to create sentinel file: %w", err) + } + return nil +} + +func processUpload(reader *multipart.Reader, contentLength int64) (string, error) { + var outPath string + + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("failed to read multipart: %w", err) + } + + if part.FormName() != "file" { + continue + } + + outPath, err = saveUploadedFile(part, contentLength) + if err != nil { + return "", err + } + } + + if outPath == "" { + return "", fmt.Errorf("no file uploaded") + } + + return outPath, nil +} + +func saveUploadedFile(part *multipart.Part, contentLength int64) (string, error) { + filename := part.FileName() + if filename == "" { + return "", fmt.Errorf("no filename provided") + } + + if err := validateFilename(filename); err != nil { + return "", err + } + + outPath := filepath.Join(CacheDir, filename) + out, err := os.Create(outPath) + if err != nil { + return "", fmt.Errorf("failed to create output file: %w", err) + } + defer out.Close() + + pw := newProgressWriter(out, contentLength) + defer pw.Stop() + + if _, err := io.Copy(pw, part); err != nil { + return "", fmt.Errorf("failed to write file: %w", err) + } + + return outPath, nil +} + +func validateFilename(filename string) error { + baseName := filepath.Base(filename) + + // Check if the path contains directory components + if baseName != filename { + log.Warnf("Path detected in filename: %s", filename) + return fmt.Errorf("path detected in filename") + } + + // Check for path traversal attempts + if strings.Contains(filename, "..") { + log.Warnf("Path traversal attempt: %s", filename) + return fmt.Errorf("invalid filename: path traversal detected") + } + + // Validate filename characters + if !validFilenameRegex.MatchString(filename) { + log.Warnf("Invalid filename characters: %s", filename) + return fmt.Errorf("invalid filename: contains invalid characters") + } + + return nil +} + +func removeSentinelFile() { + _ = os.Remove(sentinelPath) +} diff --git a/server/service/application/version.go b/server/service/application/version.go index a5ca5c8..0efdd99 100644 --- a/server/service/application/version.go +++ b/server/service/application/version.go @@ -1,7 +1,6 @@ package application import ( - "NanoKVM-Server/proto" "encoding/json" "fmt" "io" @@ -10,6 +9,8 @@ import ( "strings" "time" + "NanoKVM-Server/proto" + "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" ) @@ -36,33 +37,15 @@ func (s *Service) GetVersion(c *gin.Context) { log.Debugf("current version: %s", currentVersion) // latest version + latestVersion := "" latest, err := getLatest() - if err != nil { - rsp.ErrRsp(c, -1, "get latest version failed") - return + if err == nil && latest != nil { + latestVersion = latest.Version } rsp.OkRspWithData(c, &proto.GetVersionRsp{ Current: currentVersion, - Latest: latest.Version, - }) -} - -func (s *Service) GetCurrentVersion(c *gin.Context) { - var rsp proto.Response - - // current version - currentVersion := "1.0.0" - - versionFile := fmt.Sprintf("%s/version", AppDir) - if version, err := os.ReadFile(versionFile); err == nil { - currentVersion = strings.ReplaceAll(string(version), "\n", "") - } - - log.Debugf("current version: %s", currentVersion) - - rsp.OkRspWithData(c, &proto.GetVersionRsp{ - Current: currentVersion, + Latest: latestVersion, }) } diff --git a/web/.env.development b/web/.env.development index a202282..f698405 100644 --- a/web/.env.development +++ b/web/.env.development @@ -1,3 +1,3 @@ -VITE_SERVER_IP=192.168.3.69 +VITE_SERVER_IP=192.168.0.1 VITE_SERVER_PORT=80 VITE_WITH_CREDENTIALS=false diff --git a/web/src/api/application.ts b/web/src/api/application.ts index cf13883..594896f 100644 --- a/web/src/api/application.ts +++ b/web/src/api/application.ts @@ -1,15 +1,11 @@ import { http } from '@/lib/http.ts'; +import { getBaseUrl } from '@/lib/service.ts'; // get application version export function getVersion() { return http.get('/api/application/version'); } -// get application Current version -export function getCurrentVersion() { - return http.get('/api/application/currentversion'); -} - // update application to latest version export function update() { return http.request({ @@ -19,6 +15,16 @@ export function update() { }); } +// offline update application +export function offlineUpdate(data: FormData) { + const baseUrl = getBaseUrl('http'); + const url = `${baseUrl}/api/application/update/offline`; + return fetch(url, { + method: 'POST', + body: data + }); +} + // enable/disable preview updates export function setPreviewUpdates(enable: boolean) { const data = { @@ -31,4 +37,3 @@ export function setPreviewUpdates(enable: boolean) { export function getPreviewUpdates() { return http.get('/api/application/preview'); } - diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index 61dd2c6..a2331bf 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -14,7 +14,8 @@ const de = { placeholderPassword2: 'Bitte Passwort erneut eingeben', noEmptyUsername: 'Benutzername benötigt', noEmptyPassword: 'Passwort benötigt', - noAccount: 'Abfragen der Benutzerdaten fehlgeschlagen, bitte die Seite neu laden oder Passwort zurücksetzen', + noAccount: + 'Abfragen der Benutzerdaten fehlgeschlagen, bitte die Seite neu laden oder Passwort zurücksetzen', invalidUser: 'Falscher Benutzername oder falsches Passwort', error: 'Unerwarteter Fehler', changePassword: 'Passwort ändern', @@ -35,13 +36,15 @@ const de = { change1: 'Bitte beachten Sie, dass diese Aktion folgende Passwörter ändert:', change2: 'Web Anmelde-Passwort', change3: 'System root Passwort (SSH Anmelde-Passwort)', - change4: 'Um die Passwörter zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM.' + change4: + 'Um die Passwörter zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM.' } }, wifi: { title: 'Wi-Fi', description: 'Wi-Fi Konfiguration für NanoKVM', - success: 'Bitte überprüfen Sie den Netzwerk-Status des NanoKVM und greifen Sie über die neue IP Adresse darauf zu.', + success: + 'Bitte überprüfen Sie den Netzwerk-Status des NanoKVM und greifen Sie über die neue IP Adresse darauf zu.', failed: 'Aktion fehlgeschlagen, bitte erneut versuchen.', confirmBtn: 'Ok', finishBtn: 'Fertig' @@ -52,7 +55,7 @@ const de = { resolution: 'Auflösung', auto: 'Automatisch', autoTips: - "Bildverzerrungen oder ein versetzter Mauszeiger können bei bestimmten Auflösungen auftreten. Versuchen Sie, die Auflösung des entfernten Hosts anzupassen oder den automatischen Modus zu deaktivieren.", + 'Bildverzerrungen oder ein versetzter Mauszeiger können bei bestimmten Auflösungen auftreten. Versuchen Sie, die Auflösung des entfernten Hosts anzupassen oder den automatischen Modus zu deaktivieren.', fps: 'FPS', customizeFps: 'Anpassen', quality: 'Qualität', @@ -89,7 +92,8 @@ const de = { mode: 'Maus Modus', absolute: 'Absoluter Modus', relative: 'Relativer Modus', - requestPointer: 'Relativer Modus aktiv. Klicken Sie auf den Desktop um den Mauszeiger zu sehen.', + requestPointer: + 'Relativer Modus aktiv. Klicken Sie auf den Desktop um den Mauszeiger zu sehen.', resetHid: 'HID zurücksetzen', hidOnly: { title: 'HID-Only Modus', @@ -107,23 +111,23 @@ const de = { empty: 'Nichts gefunden', cdrom: 'Binden Sie das Abbild im CD-ROM Modus ein', mountFailed: 'Einbinden fehlgeschlagen', - mountDesc: - "In einigen Systemen ist es notwendig, die virtuelle Festplatte auf dem entfernten Host auszuwerfen, bevor das Image eingebunden werden kann.", + mountDesc: + 'In einigen Systemen ist es notwendig, die virtuelle Festplatte auf dem entfernten Host auszuwerfen, bevor das Image eingebunden werden kann.', refresh: 'Bilder aktualisieren', tips: { - title: 'So laden Sie Dateien hoch', - usb1: 'Verbinden Sie den NanoKVM über USB mit Ihrem Computer.', - usb2: 'Stellen Sie sicher, dass die virtuelle Festplatte eingebunden ist (Einstellungen – Virtuelle Festplatte).', - usb3: 'Öffnen Sie die virtuelle Festplatte auf Ihrem Computer und kopieren Sie die Image-Datei in das Stammverzeichnis der virtuellen Festplatte.', - scp1: 'Stellen Sie sicher, dass sich der NanoKVM und Ihr Computer im selben lokalen Netzwerk befinden.', - scp2: 'Öffnen Sie ein Terminal auf Ihrem Computer und verwenden Sie den SCP-Befehl, um die Image-Datei in das Verzeichnis /data auf dem NanoKVM hochzuladen.', - scp3: 'Beispiel: scp your-image-path root@your-nanokvm-ip:/data', - tfCard: 'TF-Karte', - tf1: 'Diese Methode wird unter Linux-Systemen unterstützt.', - tf2: 'Entnehmen Sie die TF-Karte aus dem NanoKVM (bei der FULL-Version muss zuvor das Gehäuse geöffnet werden).', - tf3: 'Stecken Sie die TF-Karte in einen Kartenleser und verbinden Sie diesen mit Ihrem Computer.', - tf4: 'Kopieren Sie die Image-Datei in das Verzeichnis /data auf der TF-Karte.', - tf5: 'Setzen Sie die TF-Karte wieder in den NanoKVM ein.' + title: 'So laden Sie Dateien hoch', + usb1: 'Verbinden Sie den NanoKVM über USB mit Ihrem Computer.', + usb2: 'Stellen Sie sicher, dass die virtuelle Festplatte eingebunden ist (Einstellungen – Virtuelle Festplatte).', + usb3: 'Öffnen Sie die virtuelle Festplatte auf Ihrem Computer und kopieren Sie die Image-Datei in das Stammverzeichnis der virtuellen Festplatte.', + scp1: 'Stellen Sie sicher, dass sich der NanoKVM und Ihr Computer im selben lokalen Netzwerk befinden.', + scp2: 'Öffnen Sie ein Terminal auf Ihrem Computer und verwenden Sie den SCP-Befehl, um die Image-Datei in das Verzeichnis /data auf dem NanoKVM hochzuladen.', + scp3: 'Beispiel: scp your-image-path root@your-nanokvm-ip:/data', + tfCard: 'TF-Karte', + tf1: 'Diese Methode wird unter Linux-Systemen unterstützt.', + tf2: 'Entnehmen Sie die TF-Karte aus dem NanoKVM (bei der FULL-Version muss zuvor das Gehäuse geöffnet werden).', + tf3: 'Stecken Sie die TF-Karte in einen Kartenleser und verbinden Sie diesen mit Ihrem Computer.', + tf4: 'Kopieren Sie die Image-Datei in das Verzeichnis /data auf der TF-Karte.', + tf5: 'Setzen Sie die TF-Karte wieder in den NanoKVM ein.' } }, script: { @@ -169,10 +173,11 @@ const de = { title: 'Systemabbild Downloader', input: 'Bitte geben Sie die URL für das Remote-Systemabbild ein', ok: 'Ok', - disabled: '/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden', - uploadbox: "Datei hier ablegen oder klicken zum Auswählen", - inputfile: "Bitte geben Sie die Datei für das Systemabbild an", - NoISO: "Keine ISO" + disabled: + '/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden', + uploadbox: 'Datei hier ablegen oder klicken zum Auswählen', + inputfile: 'Bitte geben Sie die Datei für das Systemabbild an', + NoISO: 'Keine ISO' }, power: { title: 'Power', @@ -203,9 +208,9 @@ const de = { hostname: 'Hostname', hostnameUpdated: 'Hostname aktualisiert. Neustarten um zu übernehmen.', ipType: { - 'Wired': 'Kabel', - 'Wireless': 'Drahtlos', - 'Other': 'Andere' + Wired: 'Kabel', + Wireless: 'Drahtlos', + Other: 'Andere' } }, appearance: { @@ -246,7 +251,7 @@ const de = { title: 'Swap', disable: 'Deaktivieren', description: 'Grösse der Swap-Datei festlegen', - tip: "Das Aktivieren dieser Funktion kann die Lebensdauer Ihrer SD-Karte verkürzen!" + tip: 'Das Aktivieren dieser Funktion kann die Lebensdauer Ihrer SD-Karte verkürzen!' }, mouseJiggler: { title: 'Mausaktivitäts-Simulator', @@ -257,7 +262,7 @@ const de = { }, mdns: { description: 'mDNS-Erkennungsdienst aktivieren', - tip: "Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen" + tip: 'Deaktivieren Sie den Dienst, wenn Sie ihn nicht benötigen' }, hidOnly: 'HID-Only Mode', disk: 'Virtuelle Festplatte', @@ -273,7 +278,7 @@ const de = { title: 'Tailscale', memory: { title: 'Speicher Optimierung', - tip: "Wenn die Speichernutzung das Limit überschreitet, wird die Speicherbereinigung aggressiver durchgeführt, um Speicher freizugeben. Es wird empfohlen, den Wert auf 75 MB zu setzen, wenn Tailscale verwendet wird. Ein Neustart von Tailscale ist erforderlich, damit die Änderung wirksam wird.", + tip: 'Wenn die Speichernutzung das Limit überschreitet, wird die Speicherbereinigung aggressiver durchgeführt, um Speicher freizugeben. Es wird empfohlen, den Wert auf 75 MB zu setzen, wenn Tailscale verwendet wird. Ein Neustart von Tailscale ist erforderlich, damit die Änderung wirksam wird.', disable: 'Deaktivieren' }, restart: 'Tailscale neu starten?', @@ -317,19 +322,16 @@ const de = { cancel: 'Abbrechen', preview: 'Vorab-Versionen', previewDesc: 'Erhalten Sie vorab Zugriff auf neue Funktionen und Verbesserungen', - previewTip: 'Bitte beachten Sie, dass Vorab-Versionen womöglich noch Fehler oder unvollständige Funktionen enthalten!' - }, - offlineupdate: { - title: 'Offline Aktualisierung', - queryFailed: 'Version konnte nicht abgefragt werden', - updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.', - updating: 'Aktualisierung gestartet. Bitte warten...', - confirm: 'Bestätigen', - cancel: 'Abbrechen', - inputfile: "Bitte geben Sie die Datei für das Update an", - noupdatefile: "Keine Update Datei", - uploadbox: "Datei hier ablegen oder zum Auswählen klicken", - ok: 'Ok' + previewTip: + 'Bitte beachten Sie, dass Vorab-Versionen womöglich noch Fehler oder unvollständige Funktionen enthalten!', + offline: { + title: 'Offline Aktualisierung', + desc: 'Über lokales Installationspaket aktualisieren', + upload: 'Hochladen', + invalidName: + 'Ungültiges Dateinamenformat. Bitte laden Sie von den GitHub-Releases herunter.', + updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.' + } }, account: { title: 'Konto', @@ -343,7 +345,7 @@ const de = { } }, error: { - title: "Wir sind auf ein Problem gestossen", + title: 'Wir sind auf ein Problem gestossen', refresh: 'Neuladen' }, fullscreen: { diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 2bd3e1e..f3cfb34 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -372,19 +372,14 @@ const en = { preview: 'Preview Updates', previewDesc: 'Get early access to new features and improvements', previewTip: - 'Please be aware that preview releases may contain bugs or incomplete functionality!' - }, - offlineupdate: { - title: 'Offline Updates', - queryFailed: 'Get version failed', - updateFailed: 'Update failed. Please retry.', - updating: 'Update started. Please wait...', - confirm: 'Confirm', - cancel: 'Cancel', - inputfile: 'Please enter the Update File', - noupdatefile: 'No update file', - uploadbox: 'Drop file here or click to select', - ok: 'Ok' + 'Please be aware that preview releases may contain bugs or incomplete functionality!', + offline: { + title: 'Offline Updates', + desc: 'Update through local installation package', + upload: 'Upload', + invalidName: 'Invalid filename format. Please download from GitHub releases.', + updateFailed: 'Update failed. Please retry.' + } }, account: { title: 'Account', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 34fb38d..29e1d6b 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -339,7 +339,14 @@ const zh = { cancel: '取消', preview: '预览更新', previewDesc: '率先体验即将推出的新功能和优化', - previewTip: '预览版更新可能包含一些不稳定因素或未完善的功能!' + previewTip: '预览版更新可能包含一些不稳定因素或未完善的功能!', + offline: { + title: '离线更新', + desc: '通过本地安装包进行更新', + upload: '上传', + invalidName: '文件名格式错误,请前往 GitHub 发布页下载安装包。', + updateFailed: '更新失败,请重试' + } }, account: { title: '帐号', diff --git a/web/src/pages/desktop/menu/settings/index.tsx b/web/src/pages/desktop/menu/settings/index.tsx index 2e1e0a9..634ff79 100644 --- a/web/src/pages/desktop/menu/settings/index.tsx +++ b/web/src/pages/desktop/menu/settings/index.tsx @@ -65,6 +65,9 @@ export const Settings = () => { if (rsp.code !== 0) { return; } + if (!rsp.data?.current || !rsp.data?.latest) { + return; + } if (semver.gt(rsp.data.latest, rsp.data.current)) { setIsUpdateAvailable(true); diff --git a/web/src/pages/desktop/menu/settings/offlineupdate/index.tsx b/web/src/pages/desktop/menu/settings/offlineupdate/index.tsx deleted file mode 100644 index 9005ea0..0000000 --- a/web/src/pages/desktop/menu/settings/offlineupdate/index.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { useEffect, useState } from 'react'; -import { LoadingOutlined, SmileOutlined } from '@ant-design/icons'; -import { Button, Input, Result, Spin } from 'antd'; -import { useTranslation } from 'react-i18next'; -import clsx from 'clsx'; - -import * as api from '@/api/application.ts'; - -type UpdateProps = { - setIsLocked: (isClosable: boolean) => void; -}; - -type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'latests' | 'failed'; - -export const OfflineUpdate = ({ setIsLocked }: UpdateProps) => { - const { t } = useTranslation(); - - const [status, setStatus] = useState(''); - const [currentVersion, setCurrentVersion] = useState(''); - const [errMsg, setErrMsg] = useState(''); - const [selectedFile, setSelectedFile] = useState(null); - const [isDragging, setIsDragging] = useState(false); - - useEffect(() => { - checkForUpdates(); - }, []); - - function checkForUpdates() { - if (status === 'loading') return; - setStatus('loading'); - - setIsLocked(true); - - api - .getCurrentVersion() - .then((rsp: any) => { - if (rsp.code !== 0 || !rsp.data) { - setStatus('failed'); - setErrMsg(t('settings.offlineupdate.queryFailed')); - return; - } - - setStatus('latest'); - setCurrentVersion(rsp.data.current); - }) - .catch(() => { - setStatus('failed'); - setErrMsg(t('settings.offlineupdate.queryFailed')); - }); - - setIsLocked(false); - } - - function handleFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0] ?? null; - if (!file || !file.name.toLowerCase().endsWith(".tar.gz")) { - setStatus('failed'); - setErrMsg(t('settings.offlineupdate.noupdatefile')); - return; - } - setSelectedFile(file); - } - - function upload(file: File | null) { - if (!file) return; - - if (!file.name.toLowerCase().endsWith(".tar.gz")) { - setStatus('failed'); - setErrMsg(t('settings.offlineupdate.noupdatefile')); - return; - } - - setStatus('updating'); - setErrMsg(''); - - const formData = new FormData(); - formData.append("file", file); - - fetch("/api/application/uploadupdate", { - method: "POST", - body: formData, - }) - .then((rsp: Response) => { - // Prüfen ob HTTP OK - if (!rsp.ok) throw new Error(`HTTP error ${rsp.status}`); - return rsp.json(); // JSON-Payload parsen - }) - .then((rspj: any) => { - // Jetzt rspj ist das tatsächliche JSON - if (rspj.code !== 0 || !rspj.data) { - setStatus('failed'); - setErrMsg(rspj.msg || 'Unknown error'); - console.log(rspj); - return; - } - checkForUpdates(); - }) - .catch((err: any) => { - setStatus('failed'); - setErrMsg(err?.message || 'Failed'); - }); -} - - - return ( - <> -
{t('settings.offlineupdate.title') + ", " + t('settings.about.application') + ": " + currentVersion}
- -
- -
- {status === 'loading' && ( -
- } size="large" /> -
- )} - - {status === 'latest' && ( -
-
{t('settings.offlineupdate.inputfile')}
-
-
{ - e.preventDefault(); - setIsDragging(false); - const file = e.dataTransfer.files?.[0] ?? null; - if (!file || !file.name.toLowerCase().endsWith(".tar.gz")) { - setStatus('failed'); - setErrMsg(t('settings.offlineupdate.noupdatefile')); - return; - } - setStatus('latest'); - setErrMsg(''); - setSelectedFile(file); - }} - onDragOver={(e) => { - e.preventDefault(); - setIsDragging(true); // Datei wird über den Bereich gezogen - }} - onDragLeave={(e) => { - e.preventDefault(); - setIsDragging(false); // Maus verlässt Bereich - }} - onClick={() => { - document.getElementById("file-upload")?.click() - }} - > - - {selectedFile ? selectedFile.name : t('settings.offlineupdate.uploadbox')} - - - -
- -
-
- )} - - {status === 'updating' && ( -
- - {t('settings.offlineupdate.updating')} -
- )} - - {status === 'latests' && ( - } - title={currentVersion} - subTitle={t('settings.offlineupdate.isLatest')} - extra={[ - - ]} - /> - )} - - {status === 'failed' && } - -
- -
-
- - ); -}; diff --git a/web/src/pages/desktop/menu/settings/update/index.tsx b/web/src/pages/desktop/menu/settings/update/index.tsx index d5472cc..4165ff9 100644 --- a/web/src/pages/desktop/menu/settings/update/index.tsx +++ b/web/src/pages/desktop/menu/settings/update/index.tsx @@ -6,18 +6,19 @@ import semver from 'semver'; import * as api from '@/api/application.ts'; +import { Offline } from './offline.tsx'; import { Preview } from './preview.tsx'; type UpdateProps = { setIsLocked: (isClosable: boolean) => void; }; -type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed'; +// type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed'; export const Update = ({ setIsLocked }: UpdateProps) => { const { t } = useTranslation(); - const [status, setStatus] = useState(''); + const [status, setStatus] = useState(''); const [currentVersion, setCurrentVersion] = useState(''); const [latestVersion, setLatestVersion] = useState(''); const [errMsg, setErrMsg] = useState(''); @@ -40,10 +41,14 @@ export const Update = ({ setIsLocked }: UpdateProps) => { } setCurrentVersion(rsp.data.current); - setLatestVersion(rsp.data.latest); - const isLatest = semver.gte(rsp.data.current, rsp.data.latest); - setStatus(isLatest ? 'latest' : 'outdated'); + if (rsp.data?.latest) { + setLatestVersion(rsp.data.latest); + const isLatest = semver.gte(rsp.data.current, rsp.data.latest); + setStatus(isLatest ? 'latest' : 'outdated'); + } else { + setStatus('latest'); + } }) .catch(() => { setStatus('failed'); @@ -81,10 +86,15 @@ export const Update = ({ setIsLocked }: UpdateProps) => { + + - - -
+
{status === 'loading' && (
} size="large" /> @@ -94,7 +104,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => { {status === 'updating' && (
- {t('settings.update.updating')} + {t('settings.update.updating')}
)} diff --git a/web/src/pages/desktop/menu/settings/update/offline.tsx b/web/src/pages/desktop/menu/settings/update/offline.tsx new file mode 100644 index 0000000..a1115a7 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/update/offline.tsx @@ -0,0 +1,120 @@ +import { useRef } from 'react'; +import { Button } from 'antd'; +import { ExternalLinkIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/application.ts'; + +interface UpdateProps { + status: string; + setStatus: (status: string) => void; + setIsLocked: (isClosable: boolean) => void; + setErrMsg: (msg: string) => void; +} + +export const Offline = ({ status, setStatus, setIsLocked, setErrMsg }: UpdateProps) => { + const { t } = useTranslation(); + + const inputRef = useRef(null); + + function handleClick() { + inputRef.current?.click(); + } + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) { + return; + } + + if (!validateFilename(file.name)) { + setStatus('failed'); + setErrMsg(t('settings.update.offline.invalidName')); + return; + } + + upload(file); + } + + function upload(file: File | null) { + if (!file) return; + + if (!validateFilename(file.name)) { + setStatus('failed'); + setErrMsg(t('settings.update.offline.invalidName')); + return; + } + + if (status === 'loading' || status === 'updating') { + return; + } + + setIsLocked(true); + setStatus('updating'); + setErrMsg(''); + + const formData = new FormData(); + formData.append('file', file); + + api + .offlineUpdate(formData) + .then((rsp: Response) => { + // Prüfen ob HTTP OK + if (!rsp.ok) throw new Error(`HTTP error ${rsp.status}`); + return rsp.json(); // JSON-Payload parsen + }) + .then((rspj: any) => { + // Jetzt rspj ist das tatsächliche JSON + if (rspj.code !== 0 || !rspj.data) { + setStatus('failed'); + setErrMsg(rspj.msg || t('settings.update.offline.updateFailed')); + console.log(rspj); + return; + } + }) + .finally(() => { + setTimeout(() => { + setIsLocked(false); + setErrMsg(''); + + window.location.reload(); + }, 12000); + }); + } + + function validateFilename(filename: string) { + const regex: RegExp = /^nanokvm_\d+\.\d+\.\d+\.tar\.gz$/; + return regex.test(filename); + } + + return ( + <> +
+
+
+ {t('settings.update.offline.title')} + + + + +
+ + {t('settings.update.offline.desc')} +
+ + + +
+ + ); +}; diff --git a/web/src/pages/desktop/menu/settings/update/preview.tsx b/web/src/pages/desktop/menu/settings/update/preview.tsx index a472d70..5a6bc7e 100644 --- a/web/src/pages/desktop/menu/settings/update/preview.tsx +++ b/web/src/pages/desktop/menu/settings/update/preview.tsx @@ -63,7 +63,7 @@ export const Preview = () => {