feat: add sha256sum check (#832)

* feat(download): add sha256sum check and cancellable downloads

+ server/proto/download.go
	+ add DownloadImageReq and sha256 argument

server/router/download.go
	+ add cancel download router

server/service/download/service.go
	+ sha256 decode and check
	+ remove HEAD request
	+ cancel download

* refactor(download): synchronize image and application transfers

sovel the the problem that image downloading and app downloading would conflict
at same time

* fix: optimized UI of image download and locale issue

* fix: locale
This commit is contained in:
Guoguo
2026-07-17 18:17:04 +08:00
committed by GitHub
31 changed files with 872 additions and 382 deletions

View File

@@ -1,5 +1,10 @@
package proto
type DownloadImageReq struct {
File string `json:"file" validate:"required"`
SHA256Sum string `json:"sha256sum"`
}
type ImageEnabledRsp struct {
Enabled bool `json:"enabled"`
}

View File

@@ -11,8 +11,9 @@ func downloadRouter(r *gin.Engine) {
service := download.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api.POST("/download/image", service.DownloadImage) // download image
api.GET("/download/image/status", service.StatusImage) // download image
api.GET("/download/image/enabled", service.ImageEnabled) // download image
api.POST("/download/file", service.DownloadImageFile) // download image
api.POST("/download/image", service.DownloadImage) // download image
api.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
api.GET("/download/image/status", service.StatusImage) // download image
api.GET("/download/image/enabled", service.ImageEnabled) // download image
api.POST("/download/file", service.DownloadImageFile) // download image
}

View File

@@ -46,10 +46,6 @@ func offlineUpdate(c *gin.Context) error {
_ = os.RemoveAll(CacheDir)
}()
if err := checkDownloadInProgress(); err != nil {
return err
}
if err := createSentinelFile(); err != nil {
return err
}
@@ -75,19 +71,30 @@ func offlineUpdate(c *gin.Context) error {
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 {
file, err := os.OpenFile(
sentinelPath,
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
sentinelPermission,
)
if err != nil {
if os.IsExist(err) {
return fmt.Errorf("download already in progress")
}
log.Errorf("Failed to create sentinel file: %v", err)
return fmt.Errorf("failed to create sentinel file: %w", err)
}
if _, err := file.WriteString("downloading"); err != nil {
_ = file.Close()
_ = os.Remove(sentinelPath)
return fmt.Errorf("failed to initialize sentinel file: %w", err)
}
if err := file.Close(); err != nil {
_ = os.Remove(sentinelPath)
return fmt.Errorf("failed to close sentinel file: %w", err)
}
return nil
}

View File

@@ -2,54 +2,165 @@ package download
import (
"NanoKVM-Server/proto"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type Service struct{}
type downloadStatus string
var sentinelPath = "/tmp/.download_in_progress"
const (
transferSentinelPath = "/tmp/.download_in_progress"
downloadStatusIdle downloadStatus = "idle"
downloadStatusInProgress downloadStatus = "in_progress"
downloadStatusSuccess downloadStatus = "success"
downloadStatusFailed downloadStatus = "failed"
downloadStatusChecksumFailed downloadStatus = "checksum_failed"
)
var (
errDownloadInProgress = errors.New("download in progress")
errSHA256Mismatch = errors.New("sha256 mismatch")
validISOFilename = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
)
type Service struct {
downloadMutex sync.Mutex
downloadCancel context.CancelFunc
downloadDone chan struct{}
downloadStatus downloadStatus
downloadFile string
downloadPercentage string
}
func NewService() *Service {
// Clear sentinel
// If we are starting from scratch, we need to remove the sentinel file as any downloads at this point are done or broken
_ = os.Remove(sentinelPath)
return &Service{}
// A running transfer cannot survive a server restart, so any remaining lock
// file is stale at this point.
_ = os.Remove(transferSentinelPath)
return &Service{downloadStatus: downloadStatusIdle}
}
func (s *Service) CancelDownloadImage(c *gin.Context) {
var rsp proto.Response
s.downloadMutex.Lock()
cancel := s.downloadCancel
done := s.downloadDone
s.downloadMutex.Unlock()
if cancel == nil || done == nil {
rsp.ErrRsp(c, -1, "no cancellable download in progress")
return
}
cancel()
select {
case <-done:
rsp.OkRsp(c)
case <-time.After(10 * time.Second):
rsp.ErrRsp(c, -1, "cancel download timed out")
}
}
func (s *Service) beginDownload(file string, cancel context.CancelFunc) (chan struct{}, error) {
s.downloadMutex.Lock()
defer s.downloadMutex.Unlock()
if s.downloadDone != nil {
return nil, errDownloadInProgress
}
lockFile, err := os.OpenFile(
transferSentinelPath,
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
0o644,
)
if err != nil {
if os.IsExist(err) {
return nil, errDownloadInProgress
}
return nil, fmt.Errorf("acquire transfer lock failed: %w", err)
}
if err := lockFile.Close(); err != nil {
_ = os.Remove(transferSentinelPath)
return nil, fmt.Errorf("close transfer lock failed: %w", err)
}
done := make(chan struct{})
s.downloadCancel = cancel
s.downloadDone = done
s.downloadStatus = downloadStatusInProgress
s.downloadFile = file
s.downloadPercentage = ""
return done, nil
}
func (s *Service) setDownloadFile(done chan struct{}, file string) {
s.downloadMutex.Lock()
defer s.downloadMutex.Unlock()
if s.downloadDone == done {
s.downloadFile = file
}
}
func (s *Service) setDownloadProgress(done chan struct{}, percentage string) {
s.downloadMutex.Lock()
defer s.downloadMutex.Unlock()
if s.downloadDone == done {
s.downloadPercentage = percentage
}
}
func (s *Service) finishDownload(done chan struct{}, status downloadStatus) {
s.downloadMutex.Lock()
if s.downloadDone != done {
s.downloadMutex.Unlock()
return
}
s.downloadCancel = nil
s.downloadDone = nil
s.downloadStatus = status
s.downloadFile = ""
s.downloadPercentage = ""
_ = os.Remove(transferSentinelPath)
s.downloadMutex.Unlock()
close(done)
}
func (s *Service) ImageEnabled(c *gin.Context) {
var rsp proto.Response
// Check if /data mount is RO/RW
testFile := "/data/.testfile"
file, err := os.Create(testFile)
defer file.Close()
defer os.Remove(testFile)
if err != nil {
if os.IsPermission(err) {
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
Enabled: false,
})
return
}
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
Enabled: false,
})
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: false})
return
}
defer file.Close()
defer os.Remove(testFile)
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
Enabled: true,
})
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: true})
}
func isISO9660(path string) (bool, error) {
@@ -59,15 +170,13 @@ func isISO9660(path string) (bool, error) {
}
defer f.Close()
// ISO-9660 Magic "CD001" bei Offset 32769
_, err = f.Seek(0x8001, io.SeekStart)
if err != nil {
// ISO-9660 magic "CD001" at offset 32769.
if _, err = f.Seek(0x8001, io.SeekStart); err != nil {
return false, err
}
buf := make([]byte, 5)
_, err = io.ReadFull(f, buf)
if err != nil {
if _, err = io.ReadFull(f, buf); err != nil {
return false, err
}
@@ -77,211 +186,141 @@ func isISO9660(path string) (bool, error) {
func (s *Service) StatusImage(c *gin.Context) {
var rsp proto.Response
// Check if the sentinel file exists
log.Debug("StatusImage")
if _, err := os.Stat(sentinelPath); err == nil {
content, err := os.ReadFile(sentinelPath)
if err != nil {
log.Error("Failed to read sentinel file")
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "in_progress",
File: "",
Percentage: "",
})
return
}
splitted := strings.Split(string(content), ";")
if len(splitted) == 1 {
// No percentage, just the URL
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "in_progress",
File: splitted[0],
Percentage: "",
})
} else {
// Percentage is available
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "in_progress",
File: splitted[0],
Percentage: splitted[1],
})
}
s.downloadMutex.Lock()
status := s.downloadStatus
file := s.downloadFile
percentage := s.downloadPercentage
s.downloadMutex.Unlock()
return
}
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "idle",
File: "",
Percentage: "",
Status: string(status),
File: file,
Percentage: percentage,
})
}
func (s *Service) DownloadImageFile(c *gin.Context) {
var rsp proto.Response
log.Debug("DownloadImage")
// 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
}
// Create the sentinel file
err := os.WriteFile(sentinelPath, []byte("start"), 0644)
log.Debug("DownloadImageFile")
expectedSHA256, err := parseSHA256(c.GetHeader("X-SHA256-Sum"))
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
rsp.ErrRsp(c, -1, err.Error())
return
}
// Multipart Reader direkt nutzen (keine FormFile!)
reader, err := c.Request.MultipartReader()
if err != nil {
done, err := s.beginDownload("", nil)
if err != nil {
rsp.ErrRsp(c, -1, err.Error())
return
}
finalStatus := downloadStatusFailed
defer func() {
s.finishDownload(done, finalStatus)
}()
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
}
return
}
var lw *loggingWriter
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
log.Error("failed to read part")
rsp.ErrRsp(c, -1, "failed to read part")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
if part.FormName() != "file" {
continue
}
filename := part.FileName()
if filename == "" {
log.Error("no filename")
rsp.ErrRsp(c, -1, "no filename")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
filename = filepath.Base(filename)
if filename != part.FileName() {
log.Warn("path detected in filename")
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
if strings.Contains(filename, "..") {
log.Warn("path traversal attempt")
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
if part.FormName() != "file" {
_ = part.Close()
continue
}
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
rsp.ErrRsp(c, -1, "only .iso files allowed")
defer os.Remove(sentinelPath)
filename := part.FileName()
if err := validateISOFilename(filename); err != nil {
_ = part.Close()
rsp.ErrRsp(c, -1, err.Error())
return
}
s.setDownloadFile(done, filename)
valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
if !valid.MatchString(filename) {
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
data, err := os.ReadFile(sentinelPath)
out, err := os.CreateTemp("/data", ".nanokvm-upload-*")
if err != nil {
log.Error("Read failed")
rsp.ErrRsp(c, -1, "Read failed")
lw.stopTicker()
defer os.Remove(sentinelPath)
_ = part.Close()
log.Error("cannot create temporary file")
rsp.ErrRsp(c, -1, "cannot create temporary file")
return
}
tempPath := out.Name()
defer os.Remove(tempPath)
outPath := "/data/" + filename
out, err := os.Create(outPath)
if err != nil {
log.Error("cannot create file")
rsp.ErrRsp(c, -1, "cannot create file")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
defer out.Close()
if strings.Contains(string(data), "start") {
err = os.WriteFile(sentinelPath, []byte(filename), 0644)
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength}
lw.startTicker()
} else {
if !strings.Contains(string(data), filename) {
log.Error("failed")
rsp.ErrRsp(c, -1, "failed")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
}
// Direkt streamen → kein RAM-Bedarf außer kleinem Buffer
_, err = io.Copy(lw, part)
if err != nil {
hasher := sha256.New()
lw := newLoggingWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(percentage string) {
s.setDownloadProgress(done, percentage)
})
_, copyErr := io.Copy(lw, part)
lw.stopTicker()
partCloseErr := part.Close()
outCloseErr := out.Close()
if copyErr != nil || partCloseErr != nil || outCloseErr != nil {
log.Error("write failed")
rsp.ErrRsp(c, -1, "write failed")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
ok, err := isISO9660(outPath)
if err != nil || !ok {
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
}
lw.stopTicker()
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "idle",
File: "",
Percentage: "",
})
defer os.Remove(sentinelPath)
return
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
finalStatus = downloadStatusChecksumFailed
rsp.ErrRsp(c, -1, errSHA256Mismatch.Error())
return
}
valid, err := isISO9660(tempPath)
if err != nil || !valid {
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
return
}
outPath := filepath.Join("/data", filename)
if err := os.Rename(tempPath, outPath); err != nil {
rsp.ErrRsp(c, -1, "cannot install uploaded image")
return
}
finalStatus = downloadStatusIdle
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: string(downloadStatusIdle),
File: "",
Percentage: "",
})
return
}
rsp.ErrRsp(c, -1, "file is required")
}
func validateISOFilename(filename string) error {
if filename == "" {
return errors.New("no filename")
}
if filepath.Base(filename) != filename || strings.Contains(filename, "..") {
return errors.New("invalid filename")
}
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
return errors.New("only .iso files allowed")
}
if !validISOFilename.MatchString(filename) {
return errors.New("invalid filename")
}
return nil
}
func (s *Service) DownloadImage(c *gin.Context) {
var req proto.MountImageReq
var req proto.DownloadImageReq
var rsp proto.Response
log.Debug("DownloadImage")
@@ -291,127 +330,190 @@ func (s *Service) DownloadImage(c *gin.Context) {
return
}
if req.File == "" {
rsp.ErrRsp(c, -1, "invalid arguments")
expectedSHA256, err := parseSHA256(req.SHA256Sum)
if err != nil {
rsp.ErrRsp(c, -1, err.Error())
return
}
// Parse the URI to see if its valid http/s
u, err := url.Parse(req.File)
if err != nil || u.Scheme == "" || u.Host == "" {
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path == "" {
rsp.ErrRsp(c, -1, "invalid url")
return
}
filename := filepath.Base(u.Path)
if filename == "." || filename == "/" || filename == "" {
rsp.ErrRsp(c, -1, "invalid url")
return
}
// 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
}
// Create the sentinel file
err = os.WriteFile(sentinelPath, []byte(req.File), 0644)
ctx, cancel := context.WithCancel(context.Background())
done, err := s.beginDownload(req.File, cancel)
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
cancel()
rsp.ErrRsp(c, -1, err.Error())
return
}
// Check if it actually exists and fail if it doesn't
resp, err := http.Head(req.File)
if resp.StatusCode != http.StatusOK || err != nil {
rsp.ErrRsp(c, resp.StatusCode, "failed when checking the url")
log.Error("Failed to check the URL")
defer os.Remove(sentinelPath)
return
}
defer resp.Body.Close()
// Download the image in a goroutine to not block the request
go func() {
defer os.Remove(sentinelPath)
resp, err = http.Get(req.File)
if err != nil {
log.Error("Failed to download the file")
rsp.ErrRsp(c, -1, "failed to download the file")
return
}
defer resp.Body.Close()
// Create the destination file
destPath := filepath.Join("/data", filepath.Base(u.Path))
out, err := os.Create(destPath)
if err != nil {
log.Error("Failed to create destination file")
rsp.ErrRsp(c, -1, "failed to create destination file")
return
}
defer out.Close()
defer cancel()
lw := &loggingWriter{writer: out, totalSize: resp.ContentLength}
lw.startTicker()
_, err = io.Copy(lw, resp.Body)
if err != nil {
log.Error("Failed to save the file")
rsp.ErrRsp(c, -1, "failed to save the file")
lw.stopTicker()
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(percentage string) {
s.setDownloadProgress(done, percentage)
}); err != nil {
if errors.Is(err, context.Canceled) {
log.Debug("Image download canceled")
s.finishDownload(done, downloadStatusIdle)
return
}
log.Errorf("Failed to download image: %v", err)
status := downloadStatusFailed
if errors.Is(err, errSHA256Mismatch) {
status = downloadStatusChecksumFailed
}
s.finishDownload(done, status)
return
}
lw.stopTicker()
s.finishDownload(done, downloadStatusSuccess)
}()
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "in_progress",
Status: string(downloadStatusInProgress),
File: req.File,
Percentage: "",
})
}
func parseSHA256(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
sum, err := hex.DecodeString(value)
if err != nil || len(sum) != sha256.Size {
return nil, errors.New("invalid sha256sum")
}
return sum, nil
}
func (s *Service) downloadRemoteImage(
ctx context.Context,
rawURL string,
expectedSHA256 []byte,
filename string,
onProgress func(string),
) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return fmt.Errorf("create download request failed: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download request returned status %d", resp.StatusCode)
}
tempFile, err := os.CreateTemp("/data", ".nanokvm-download-*")
if err != nil {
return fmt.Errorf("create temporary image failed: %w", err)
}
tempPath := tempFile.Name()
defer os.Remove(tempPath)
hasher := sha256.New()
lw := newLoggingWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
_, copyErr := io.Copy(lw, resp.Body)
lw.stopTicker()
closeErr := tempFile.Close()
if ctx.Err() != nil {
return ctx.Err()
}
if copyErr != nil {
return fmt.Errorf("save downloaded image failed: %w", copyErr)
}
if closeErr != nil {
return fmt.Errorf("close downloaded image failed: %w", closeErr)
}
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
return errSHA256Mismatch
}
if ctx.Err() != nil {
return ctx.Err()
}
destPath := filepath.Join("/data", filename)
if err := os.Rename(tempPath, destPath); err != nil {
return fmt.Errorf("install downloaded image failed: %w", err)
}
return nil
}
type loggingWriter struct {
writer io.Writer
total int64
totalSize int64
ticker *time.Ticker
done chan bool
writer io.Writer
total atomic.Int64
totalSize int64
ticker *time.Ticker
done chan struct{}
stopOnce sync.Once
onProgress func(string)
}
func newLoggingWriter(writer io.Writer, totalSize int64, onProgress func(string)) *loggingWriter {
lw := &loggingWriter{
writer: writer,
totalSize: totalSize,
onProgress: onProgress,
}
lw.startTicker()
return lw
}
func (lw *loggingWriter) startTicker() {
lw.ticker = time.NewTicker(2500 * time.Millisecond)
lw.done = make(chan bool)
lw.done = make(chan struct{})
go func() {
for {
select {
case <-lw.done:
return
case <-lw.ticker.C:
lw.updateSentinel()
lw.updateProgress()
}
}
}()
}
func (lw *loggingWriter) stopTicker() {
lw.ticker.Stop()
lw.done <- true
if lw == nil {
return
}
lw.stopOnce.Do(func() {
lw.ticker.Stop()
close(lw.done)
})
}
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")
func (lw *loggingWriter) updateProgress() {
if lw.totalSize <= 0 || lw.onProgress == nil {
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")
}
percentage := float64(lw.total.Load()) / float64(lw.totalSize) * 100
lw.onProgress(fmt.Sprintf("%.2f%%", percentage))
}
func (lw *loggingWriter) Write(p []byte) (int, error) {
n, err := lw.writer.Write(p)
lw.total += int64(n)
lw.total.Add(int64(n))
return n, err
}

View File

@@ -1,13 +1,18 @@
import { http } from '@/lib/http.ts';
// Download image
export function downloadImage(file?: string) {
export function downloadImage(file?: string, sha256sum?: string) {
const data = {
file: file ? file : ''
};
file: file ?? '',
sha256sum: sha256sum ?? ''
};
return http.post('/api/download/image', data);
}
export function cancelDownloadImage() {
return http.post('/api/download/image/cancel');
}
export function statusImage() {
return http.get('/api/download/image/status');
}

View File

@@ -250,7 +250,15 @@ const ca = {
disabled: 'La partició /data és només lectura. No es pot descarregar la imatge.',
uploadbox: 'Deixeu anar el fitxer aquí o feu clic per seleccionar-lo',
inputfile: "Introduïu el fitxer d'imatge",
NoISO: 'Cap ISO'
NoISO: 'Cap ISO',
sha256: 'SHA-256 (opcional)',
sha256Placeholder: 'Introduïu una suma de verificació SHA-256 de 64 caràcters',
invalidSHA256: 'SHA-256 ha de ser una cadena hexadecimal de 64 caràcters',
failed: 'Descàrrega fallida',
success: 'Descàrrega correcta',
checksumFailed: 'Descàrrega fallida: ha fallat la verificació SHA-256',
cancel: 'Cancel·la',
cancelFailed: 'No sha pogut cancel·lar la descàrrega'
},
power: {
title: 'Alimentació',

View File

@@ -253,7 +253,15 @@ const cz = {
disabled: 'Oddíl /data je RO, takže obrázek nelze stáhnout',
uploadbox: 'Přetáhněte soubor sem nebo kliknutím vyberte',
inputfile: 'Zadejte soubor obrázku',
NoISO: 'Žádné ISO'
NoISO: 'Žádné ISO',
sha256: 'SHA-256 (volitelné)',
sha256Placeholder: 'Zadejte 64znakový kontrolní součet SHA-256',
invalidSHA256: 'SHA-256 musí být 64znakový hexadecimální řetězec',
failed: 'Stažení se nezdařilo',
success: 'Stažení proběhlo úspěšně',
checksumFailed: 'Stažení se nezdařilo: ověření SHA-256 selhalo',
cancel: 'Zrušit',
cancelFailed: 'Stažení se nepodařilo zrušit'
},
power: {
title: 'Napájení',

View File

@@ -251,7 +251,15 @@ const da = {
disabled: '/data partitionen er RO, så vi kan ikke downloade billedet',
uploadbox: 'Slip filen her, eller klik for at vælge',
inputfile: 'Indtast venligst billedfilen',
NoISO: 'Ingen ISO'
NoISO: 'Ingen ISO',
sha256: 'SHA-256 (valgfri)',
sha256Placeholder: 'Indtast en SHA-256-kontrolsum på 64 tegn',
invalidSHA256: 'SHA-256 skal være en hexadecimal streng på 64 tegn',
failed: 'Download mislykkedes',
success: 'Download gennemført',
checksumFailed: 'Download mislykkedes: SHA-256-verifikation mislykkedes',
cancel: 'Annuller',
cancelFailed: 'Kunne ikke annullere download'
},
power: {
title: 'Tænd/sluk-knap',

View File

@@ -256,7 +256,15 @@ const de = {
'/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'
NoISO: 'Keine ISO',
sha256: 'SHA-256 (optional)',
sha256Placeholder: 'Geben Sie eine 64-stellige SHA-256-Prüfsumme ein',
invalidSHA256: 'SHA-256 muss eine 64-stellige Hexadezimalzeichenfolge sein',
failed: 'Download fehlgeschlagen',
success: 'Download erfolgreich',
checksumFailed: 'Download fehlgeschlagen: SHA-256-Prüfung fehlgeschlagen',
cancel: 'Abbrechen',
cancelFailed: 'Download konnte nicht abgebrochen werden'
},
power: {
title: 'Power',

View File

@@ -250,7 +250,15 @@ const en = {
disabled: '/data partition is RO, so we cannot download the image',
uploadbox: 'Drop file here or click to select',
inputfile: 'Please enter the image File',
NoISO: 'No ISO'
NoISO: 'No ISO',
sha256: 'SHA-256 (optional)',
sha256Placeholder: 'Enter a 64-character SHA-256 checksum',
invalidSHA256: 'SHA-256 must be a 64-character hexadecimal string',
failed: 'Download failed',
success: 'Download successful',
checksumFailed: 'Download failed: SHA-256 verification failed',
cancel: 'Cancel',
cancelFailed: 'Failed to cancel download'
},
power: {
title: 'Power',

View File

@@ -253,7 +253,15 @@ const es = {
disabled: 'La partición /data es de sólo lectura, no se puede descargar la imagen',
uploadbox: 'Suelte el archivo aquí o haga clic para seleccionar',
inputfile: 'Por favor ingrese el archivo de imagen',
NoISO: 'Sin ISO'
NoISO: 'Sin ISO',
sha256: 'SHA-256 (opcional)',
sha256Placeholder: 'Introduzca una suma de comprobación SHA-256 de 64 caracteres',
invalidSHA256: 'SHA-256 debe ser una cadena hexadecimal de 64 caracteres',
failed: 'Descarga fallida',
success: 'Descarga correcta',
checksumFailed: 'Descarga fallida: error en la verificación SHA-256',
cancel: 'Cancelar',
cancelFailed: 'No se pudo cancelar la descarga'
},
power: {
title: 'Encender / Apagar',

View File

@@ -255,7 +255,15 @@ const fr = {
disabled: 'La partition /data est en lecture seule, impossible de télécharger limage',
uploadbox: 'Déposez le fichier ici ou cliquez pour sélectionner',
inputfile: 'Veuillez saisir le fichier image',
NoISO: 'Aucun ISO'
NoISO: 'Aucun ISO',
sha256: 'SHA-256 (facultatif)',
sha256Placeholder: 'Saisissez une somme de contrôle SHA-256 de 64 caractères',
invalidSHA256: 'SHA-256 doit être une chaîne hexadécimale de 64 caractères',
failed: 'Échec du téléchargement',
success: 'Téléchargement réussi',
checksumFailed: 'Échec du téléchargement : échec de la vérification SHA-256',
cancel: 'Annuler',
cancelFailed: 'Impossible dannuler le téléchargement'
},
power: {
title: 'Power',

View File

@@ -254,7 +254,15 @@ const hu = {
disabled: '/data partíció RO, ezért nem tudjuk letölteni a képet',
uploadbox: 'Dobja ide a fájlt, vagy kattintson a kiválasztáshoz',
inputfile: 'Kérjük, írja be a képfájlt',
NoISO: 'Nincs ISO'
NoISO: 'Nincs ISO',
sha256: 'SHA-256 (opcionális)',
sha256Placeholder: 'Adjon meg egy 64 karakteres SHA-256 ellenőrzőösszeget',
invalidSHA256: 'A SHA-256 értékének 64 karakteres hexadecimális karakterláncnak kell lennie',
failed: 'Sikertelen letöltés',
success: 'Sikeres letöltés',
checksumFailed: 'Sikertelen letöltés: a SHA-256 ellenőrzése sikertelen',
cancel: 'Mégse',
cancelFailed: 'A letöltés megszakítása sikertelen'
},
power: {
title: 'Bekapcsolás',

View File

@@ -252,7 +252,15 @@ const id = {
disabled: 'Partisi /data adalah RO, jadi kami tidak dapat mengunduh gambarnya',
uploadbox: 'Letakkan file di sini atau klik untuk memilih',
inputfile: 'Silakan masukkan File gambar',
NoISO: 'Tidak ada ISO'
NoISO: 'Tidak ada ISO',
sha256: 'SHA-256 (opsional)',
sha256Placeholder: 'Masukkan checksum SHA-256 64 karakter',
invalidSHA256: 'SHA-256 harus berupa string heksadesimal 64 karakter',
failed: 'Unduhan gagal',
success: 'Unduhan berhasil',
checksumFailed: 'Unduhan gagal: verifikasi SHA-256 gagal',
cancel: 'Batal',
cancelFailed: 'Gagal membatalkan unduhan'
},
power: {
title: 'Daya',

View File

@@ -254,7 +254,15 @@ const it = {
disabled: "La partizione /data è RO, quindi non possiamo scaricare l'immagine",
uploadbox: 'Rilascia il file qui o fai clic per selezionarlo',
inputfile: 'Inserisci il file immagine',
NoISO: 'Nessuna ISO'
NoISO: 'Nessuna ISO',
sha256: 'SHA-256 (facoltativo)',
sha256Placeholder: 'Inserisci un checksum SHA-256 di 64 caratteri',
invalidSHA256: 'SHA-256 deve essere una stringa esadecimale di 64 caratteri',
failed: 'Download non riuscito',
success: 'Download riuscito',
checksumFailed: 'Download non riuscito: verifica SHA-256 non riuscita',
cancel: 'Annulla',
cancelFailed: 'Impossibile annullare il download'
},
power: {
title: 'Accensione',

View File

@@ -253,7 +253,15 @@ const ja = {
'/data パーティションは読み取り専用であり、イメージのダウンロードには使用できません',
uploadbox: 'ここにファイルをドロップするか、クリックして選択してください',
inputfile: '画像ファイルを入力してください',
NoISO: 'ISO なし'
NoISO: 'ISO なし',
sha256: 'SHA-256任意',
sha256Placeholder: '64 文字の SHA-256 チェックサムを入力してください',
invalidSHA256: 'SHA-256 は 64 文字の 16 進数文字列である必要があります',
failed: 'ダウンロードに失敗しました',
success: 'ダウンロードに成功しました',
checksumFailed: 'ダウンロードに失敗しましたSHA-256 検証に失敗しました',
cancel: 'キャンセル',
cancelFailed: 'ダウンロードのキャンセルに失敗しました'
},
power: {
title: '電源',

View File

@@ -248,7 +248,15 @@ const ko = {
disabled: '/data 파티션이 읽기 전용(RO) 상태이므로 이미지를 다운로드할 수 없습니다.',
uploadbox: '여기에 파일을 놓거나 클릭하여 선택하세요.',
inputfile: '이미지 파일을 입력해주세요',
NoISO: 'ISO 없음'
NoISO: 'ISO 없음',
sha256: 'SHA-256 (선택 사항)',
sha256Placeholder: '64자 SHA-256 체크섬을 입력하세요',
invalidSHA256: 'SHA-256은 64자의 16진수 문자열이어야 합니다',
failed: '다운로드 실패',
success: '다운로드 성공',
checksumFailed: '다운로드 실패: SHA-256 검증 실패',
cancel: '취소',
cancelFailed: '다운로드 취소 실패'
},
power: {
title: '전원',

View File

@@ -252,7 +252,15 @@ const nb = {
disabled: '/data partisjonen er RO, så vi kan ikke laste ned bildet',
uploadbox: 'Slipp filen her eller klikk for å velge',
inputfile: 'Vennligst skriv inn bildefilen',
NoISO: 'Ingen ISO'
NoISO: 'Ingen ISO',
sha256: 'SHA-256 (valgfritt)',
sha256Placeholder: 'Skriv inn en SHA-256-kontrollsum på 64 tegn',
invalidSHA256: 'SHA-256 må være en heksadesimal streng på 64 tegn',
failed: 'Nedlasting mislyktes',
success: 'Nedlasting fullført',
checksumFailed: 'Nedlasting mislyktes: SHA-256-verifisering mislyktes',
cancel: 'Avbryt',
cancelFailed: 'Kunne ikke avbryte nedlastingen'
},
power: {
title: 'På-knapp',

View File

@@ -254,7 +254,15 @@ const nl = {
disabled: '/data partitie is RO, dus we kunnen de afbeelding niet downloaden',
uploadbox: 'Zet het bestand hier neer of klik om te selecteren',
inputfile: 'Voer het afbeeldingsbestand in',
NoISO: 'Geen ISO'
NoISO: 'Geen ISO',
sha256: 'SHA-256 (optioneel)',
sha256Placeholder: 'Voer een SHA-256-controlesom van 64 tekens in',
invalidSHA256: 'SHA-256 moet een hexadecimale tekenreeks van 64 tekens zijn',
failed: 'Download mislukt',
success: 'Download geslaagd',
checksumFailed: 'Download mislukt: SHA-256-verificatie mislukt',
cancel: 'Annuleren',
cancelFailed: 'Download annuleren mislukt'
},
power: {
title: 'Aan/uit',

View File

@@ -253,7 +253,15 @@ const pl = {
disabled: '/data partycja to RO, więc nie możemy pobrać obrazu',
uploadbox: 'Upuść plik tutaj lub kliknij, aby wybrać',
inputfile: 'Proszę wprowadzić plik obrazu',
NoISO: 'Brak ISO'
NoISO: 'Brak ISO',
sha256: 'SHA-256 (opcjonalnie)',
sha256Placeholder: 'Wprowadź 64-znakową sumę kontrolną SHA-256',
invalidSHA256: 'SHA-256 musi być 64-znakowym ciągiem szesnastkowym',
failed: 'Pobieranie nie powiodło się',
success: 'Pobieranie zakończone pomyślnie',
checksumFailed: 'Pobieranie nie powiodło się: weryfikacja SHA-256 nie powiodła się',
cancel: 'Anuluj',
cancelFailed: 'Nie udało się anulować pobierania'
},
power: {
title: 'Zasilanie',

View File

@@ -252,7 +252,15 @@ const pt_br = {
disabled: 'A partição /data é RO, então não podemos baixar a imagem',
uploadbox: 'Solte o arquivo aqui ou clique para selecionar',
inputfile: 'Por favor insira o arquivo de imagem',
NoISO: 'Sem ISO'
NoISO: 'Sem ISO',
sha256: 'SHA-256 (opcional)',
sha256Placeholder: 'Digite um checksum SHA-256 de 64 caracteres',
invalidSHA256: 'SHA-256 deve ser uma sequência hexadecimal de 64 caracteres',
failed: 'Falha no download',
success: 'Download concluído',
checksumFailed: 'Falha no download: a verificação SHA-256 falhou',
cancel: 'Cancelar',
cancelFailed: 'Falha ao cancelar o download'
},
power: {
title: 'Energia',

View File

@@ -252,7 +252,15 @@ const ru = {
disabled: 'Невозможно скачать образ, раздел /data находится в режиме только для чтения',
uploadbox: 'Перетащите сюда файл или нажмите, чтобы выбрать',
inputfile: 'Пожалуйста, введите файл изображения',
NoISO: 'Нет ISO'
NoISO: 'Нет ISO',
sha256: 'SHA-256 (необязательно)',
sha256Placeholder: 'Введите контрольную сумму SHA-256 из 64 символов',
invalidSHA256: 'SHA-256 должен быть шестнадцатеричной строкой из 64 символов',
failed: 'Ошибка загрузки',
success: 'Загрузка выполнена',
checksumFailed: 'Ошибка загрузки: проверка SHA-256 не пройдена',
cancel: 'Отмена',
cancelFailed: 'Не удалось отменить загрузку'
},
power: {
title: 'Питание',

View File

@@ -249,7 +249,15 @@ const se = {
disabled: '/data partitionen är skrivskyddad, kan inte hämta avbildning',
uploadbox: 'Släpp filen här eller klicka för att välja',
inputfile: 'Vänligen ange bildfilen',
NoISO: 'Ingen ISO'
NoISO: 'Ingen ISO',
sha256: 'SHA-256 (valfrie)',
sha256Placeholder: 'Skriv inn en SHA-256-kontrollsum på 64 tegn',
invalidSHA256: 'SHA-256 må være en heksadesimal streng på 64 tegn',
failed: 'Nedlasting mislyktes',
success: 'Nedlasting fullført',
checksumFailed: 'Nedlasting mislyktes: SHA-256-verifisering mislyktes',
cancel: 'Avbryt',
cancelFailed: 'Kunne ikke avbryte nedlastingen'
},
power: {
title: 'Ström',

View File

@@ -246,7 +246,15 @@ const th = {
disabled: '/data มีการตั้งค่าเป็นอ่านอย่างเดียว ดังนั้นเราไม่สามารถดาวโหลด Disk Image ได้',
uploadbox: 'วางไฟล์ที่นี่หรือคลิกเพื่อเลือก',
inputfile: 'กรุณากรอกไฟล์ภาพ',
NoISO: 'ไม่มี ISO'
NoISO: 'ไม่มี ISO',
sha256: 'SHA-256 (ไม่บังคับ)',
sha256Placeholder: 'ป้อน checksum SHA-256 64 ตัวอักษร',
invalidSHA256: 'SHA-256 ต้องเป็นสตริงเลขฐานสิบหก 64 ตัวอักษร',
failed: 'ดาวน์โหลดล้มเหลว',
success: 'ดาวน์โหลดสำเร็จ',
checksumFailed: 'ดาวน์โหลดล้มเหลว: การตรวจสอบ SHA-256 ล้มเหลว',
cancel: 'ยกเลิก',
cancelFailed: 'ยกเลิกการดาวน์โหลดไม่สำเร็จ'
},
power: {
title: 'เปิด/ปิด',

View File

@@ -251,7 +251,15 @@ const tr = {
disabled: '/data bölüntüsü salt okunur modda, disk imajı indirilemiyor.',
uploadbox: 'Dosyayı buraya bırakın veya seçmek için tıklayın',
inputfile: 'Lütfen resim dosyasını giriniz',
NoISO: 'ISO yok'
NoISO: 'ISO yok',
sha256: 'SHA-256 (isteğe bağlı)',
sha256Placeholder: '64 karakterlik SHA-256 sağlama toplamını girin',
invalidSHA256: 'SHA-256, 64 karakterlik bir onaltılık dize olmalıdır',
failed: 'İndirme başarısız',
success: 'İndirme başarılı',
checksumFailed: 'İndirme başarısız: SHA-256 doğrulaması başarısız',
cancel: 'İptal',
cancelFailed: 'İndirme iptal edilemedi'
},
power: {
title: 'Güç',

View File

@@ -253,7 +253,15 @@ const uk = {
disabled: 'Розділ даних /data у режимі лише для читання, тому ми не можемо завантажити образ',
uploadbox: 'Перетягніть файл сюди або натисніть, щоб вибрати',
inputfile: 'Будь ласка, введіть файл зображення',
NoISO: 'Немає ISO'
NoISO: 'Немає ISO',
sha256: 'SHA-256 (необов’язково)',
sha256Placeholder: 'Введіть контрольну суму SHA-256 із 64 символів',
invalidSHA256: 'SHA-256 має бути шістнадцятковим рядком із 64 символів',
failed: 'Помилка завантаження',
success: 'Завантаження успішне',
checksumFailed: 'Помилка завантаження: перевірка SHA-256 не пройдена',
cancel: 'Скасувати',
cancelFailed: 'Не вдалося скасувати завантаження'
},
power: {
title: 'Живлення',

View File

@@ -250,7 +250,15 @@ const vi = {
disabled: '/data phân vùng là RO nên không tải được image',
uploadbox: 'Thả file vào đây hoặc bấm vào để chọn',
inputfile: 'Vui lòng nhập File hình ảnh',
NoISO: 'Không có ISO'
NoISO: 'Không có ISO',
sha256: 'SHA-256 (tùy chọn)',
sha256Placeholder: 'Nhập mã kiểm tra SHA-256 gồm 64 ký tự',
invalidSHA256: 'SHA-256 phải là chuỗi thập lục phân gồm 64 ký tự',
failed: 'Tải xuống thất bại',
success: 'Tải xuống thành công',
checksumFailed: 'Tải xuống thất bại: xác minh SHA-256 không thành công',
cancel: 'Hủy',
cancelFailed: 'Không thể hủy tải xuống'
},
power: {
title: 'Nguồn',

View File

@@ -241,8 +241,16 @@ const zh = {
ok: '确定',
disabled: '/data 是只读分区,无法下载镜像',
uploadbox: '将文件拖放到此处或单击选择',
inputfile: '请输入图片文件',
NoISO: '无 ISO'
inputfile: '请输入镜像文件',
NoISO: '无 ISO',
sha256: 'SHA-256可选',
sha256Placeholder: '请输入 64 位 SHA-256 校验和',
invalidSHA256: 'SHA-256 必须是 64 位十六进制字符串',
failed: '下载失败',
success: '下载成功',
checksumFailed: '下载失败SHA-256 校验失败',
cancel: '取消',
cancelFailed: '取消下载失败'
},
power: {
title: '电源',

View File

@@ -241,8 +241,16 @@ const zh_tw = {
ok: '確定',
disabled: '/data 為唯讀目錄,無法下載映像檔',
uploadbox: '將檔案拖曳到此處或按一下選擇',
inputfile: '請輸入圖片檔案',
NoISO: '無 ISO'
inputfile: '請輸入映像檔案',
NoISO: '無 ISO',
sha256: 'SHA-256可選',
sha256Placeholder: '請輸入 64 位元 SHA-256 校驗和',
invalidSHA256: 'SHA-256 必須是 64 位元十六進位字串',
failed: '下載失敗',
success: '下載成功',
checksumFailed: '下載失敗SHA-256 校驗失敗',
cancel: '取消',
cancelFailed: '取消下載失敗'
},
power: {
title: '電源控制',

View File

@@ -6,17 +6,27 @@ import { useSetAtom } from 'jotai';
import { DownloadIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { downloadImage, imageEnabled, statusImage } from '@/api/download.ts';
import {
cancelDownloadImage,
downloadImage,
imageEnabled,
statusImage
} from '@/api/download.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { MenuItem } from '@/components/menu-item.tsx';
const imageUpdatedEvent = 'nanokvm:image-updated';
export const DownloadImage = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [input, setInput] = useState('');
const [sha256sum, setSha256sum] = useState('');
const [status, setStatus] = useState('');
const [log, setLog] = useState('');
const [isCancelling, setIsCancelling] = useState(false);
const [isRemoteDownloading, setIsRemoteDownloading] = useState(false);
const [diskEnabled, setDiskEnabled] = useState(false);
const [popoverKey, setPopoverKey] = useState(0);
@@ -25,6 +35,10 @@ export const DownloadImage = () => {
const [isDragging, setIsDragging] = useState(false);
const intervalId = useRef<NodeJS.Timeout | undefined>(undefined);
const pollingGeneration = useRef(0);
const remoteDownloadActive = useRef(false);
const fileUploadActive = useRef(false);
const downloadRequestGeneration = useRef(0);
useEffect(() => {
checkDiskEnabled();
@@ -42,22 +56,24 @@ export const DownloadImage = () => {
function handleOpenChange(open: boolean) {
if (open) {
clearInterval(intervalId.current);
checkDiskEnabled();
getDownloadStatus();
if (!intervalId.current) {
intervalId.current = setInterval(getDownloadStatus, 2500);
}
startStatusPolling();
setIsKeyboardEnable(false);
setPopoverKey((prevKey) => prevKey + 1); // Force re-render
} else {
setInput('');
setStatus('');
setLog('');
setIsKeyboardEnable(true);
clearInterval(intervalId.current);
intervalId.current = undefined;
// Keep monitoring an active remote download after the popover closes so
// completion can still refresh an already-open image list.
if (!remoteDownloadActive.current) {
setInput('');
setSha256sum('');
setStatus('');
setLog('');
setIsCancelling(false);
setIsRemoteDownloading(false);
stopStatusPolling();
}
}
}
@@ -65,11 +81,59 @@ export const DownloadImage = () => {
setInput(e.target.value);
}
function getDownloadStatus() {
function handleSha256Change(e: ChangeEvent<HTMLInputElement>) {
setSha256sum(e.target.value);
}
function getValidatedSHA256() {
const checksum = sha256sum.trim();
if (checksum && !/^[a-fA-F0-9]{64}$/.test(checksum)) {
setStatus('failed');
setLog(t('download.invalidSHA256'));
return null;
}
return checksum;
}
function startStatusPolling() {
stopStatusPolling();
const generation = pollingGeneration.current;
getDownloadStatus(generation);
intervalId.current = setInterval(() => getDownloadStatus(generation), 2500);
}
function stopStatusPolling() {
pollingGeneration.current += 1;
clearInterval(intervalId.current);
intervalId.current = undefined;
}
function finishImageTransfer(refreshImages: boolean) {
stopStatusPolling();
remoteDownloadActive.current = false;
fileUploadActive.current = false;
setIsRemoteDownloading(false);
setStatus('success');
setLog(t('download.success'));
if (refreshImages) {
window.dispatchEvent(new Event(imageUpdatedEvent));
}
}
function getDownloadStatus(generation = pollingGeneration.current) {
statusImage().then((rsp) => {
// Ignore a response from a previous polling session. This can happen when
// a download is started while the initial status request is still pending.
if (generation !== pollingGeneration.current) return;
if (rsp.data.status) {
setStatus(rsp.data.status);
if (rsp.data.status === 'in_progress') {
const isRemoteDownload = /^https?:\/\//.test(rsp.data.file);
remoteDownloadActive.current = isRemoteDownload;
setIsRemoteDownloading(isRemoteDownload);
// Check if rsp has a percentage value
if (rsp.data.percentage) {
setLog('Downloading (' + rsp.data.percentage + ')' + ': ' + rsp.data.file);
@@ -78,13 +142,29 @@ export const DownloadImage = () => {
}
setInput(rsp.data.file);
}
if (rsp.data.status === 'checksum_failed') {
remoteDownloadActive.current = false;
setIsRemoteDownloading(false);
setLog(t('download.checksumFailed'));
stopStatusPolling();
}
if (rsp.data.status === 'failed') {
setLog('Failed');
clearInterval(intervalId.current);
remoteDownloadActive.current = false;
setIsRemoteDownloading(false);
setLog(t('download.failed'));
stopStatusPolling();
}
if (rsp.data.status === 'success') {
const completedRemoteDownload = remoteDownloadActive.current;
finishImageTransfer(completedRemoteDownload);
}
if (rsp.data.status === 'idle') {
setLog(''); // Clear the log
clearInterval(intervalId.current);
if (fileUploadActive.current) return;
remoteDownloadActive.current = false;
setIsRemoteDownloading(false);
setLog('');
stopStatusPolling();
}
}
});
@@ -93,22 +173,71 @@ export const DownloadImage = () => {
function download(url?: string) {
if (!url) return;
const checksum = getValidatedSHA256();
if (checksum === null) return;
// Invalidate the status request started when the popover was opened.
// Start polling only after the download request has created the server-side
// download state, otherwise the first response can still be `idle`.
stopStatusPolling();
const requestGeneration = ++downloadRequestGeneration.current;
remoteDownloadActive.current = true;
setIsRemoteDownloading(true);
setStatus('in_progress');
setLog('Downloading: ' + url);
// start the getDownloadStatus to tick every 5 seconds
downloadImage(url)
.then(() => {
getDownloadStatus();
// Start the interval to check the download status
if (!intervalId.current) {
intervalId.current = setInterval(getDownloadStatus, 2500);
downloadImage(url, checksum)
.then((rsp) => {
if (requestGeneration !== downloadRequestGeneration.current) return;
if (rsp.code !== 0) {
stopStatusPolling();
remoteDownloadActive.current = false;
setIsRemoteDownloading(false);
setStatus('failed');
setLog(rsp.msg || t('download.failed'));
return;
}
startStatusPolling();
})
.catch(() => {
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
if (requestGeneration !== downloadRequestGeneration.current) return;
stopStatusPolling();
remoteDownloadActive.current = false;
setIsRemoteDownloading(false);
setStatus('failed');
setLog('Failed');
setLog(t('download.failed'));
});
}
function cancelDownload() {
if (isCancelling) return;
downloadRequestGeneration.current += 1;
setIsCancelling(true);
cancelDownloadImage()
.then((rsp) => {
if (rsp.code !== 0) {
setLog(rsp.msg || t('download.cancelFailed'));
if (remoteDownloadActive.current) {
startStatusPolling();
}
return;
}
stopStatusPolling();
remoteDownloadActive.current = false;
setIsRemoteDownloading(false);
setStatus('idle');
setLog('');
})
.catch(() => {
setLog(t('download.cancelFailed'));
})
.finally(() => {
setIsCancelling(false);
});
}
@@ -119,11 +248,12 @@ export const DownloadImage = () => {
setLog(t('download.NoISO'));
return;
}
setIsRemoteDownloading(false);
remoteDownloadActive.current = false;
setStatus('idle');
setLog('');
setSelectedFile(file);
clearInterval(intervalId.current);
intervalId.current = undefined;
stopStatusPolling();
}
function upload(file: File | null) {
@@ -135,7 +265,13 @@ export const DownloadImage = () => {
return;
}
const checksum = getValidatedSHA256();
if (checksum === null) return;
setStatus('in_progress');
remoteDownloadActive.current = false;
fileUploadActive.current = true;
setIsRemoteDownloading(false);
setLog('Downloading: ' + file.name);
const formData = new FormData();
@@ -143,24 +279,30 @@ export const DownloadImage = () => {
fetch("/api/download/file", {
method: "POST",
headers: {
'X-SHA256-Sum': checksum
},
body: formData,
}).catch(() => {
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
})
.then(async (response) => {
const rsp = await response.json();
if (!response.ok || rsp.code !== 0) {
const message =
rsp.msg === 'sha256 mismatch' ? t('download.checksumFailed') : rsp.msg;
throw new Error(message || t('download.failed'));
}
finishImageTransfer(true);
setSelectedFile(null);
})
.catch((error: unknown) => {
fileUploadActive.current = false;
stopStatusPolling();
setStatus('failed');
setLog('Failed');
}).then(() => {
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
setStatus('idle');
setLog('');
setLog(error instanceof Error ? error.message : t('download.failed'));
});
// Start the interval to check the download status
if (!intervalId.current) {
getDownloadStatus();
setTimeout(() => {
intervalId.current = setInterval(getDownloadStatus, 2500);
}, 2500);
}
startStatusPolling();
}
@@ -175,34 +317,57 @@ export const DownloadImage = () => {
{!diskEnabled ? (
<div className="text-red-500">{t('download.disabled')}</div>
) : (
<>
<div className="space-y-2">
<div>
<div className="pb-1 text-neutral-500">{t('download.input')}</div>
<div className="flex items-center space-x-1">
<div className="mb-1 text-neutral-500">{t('download.input')}</div>
<div className="flex items-center gap-1">
<Input
ref={inputRef}
value={input}
onChange={handleChange}
disabled={status === 'in_progress'}
className="min-w-0 flex-1"
/>
<Button
type="primary"
onClick={() => download(input)}
disabled={status === 'in_progress'}
className="h-10 w-16 shrink-0 px-0"
danger={isRemoteDownloading && status === 'in_progress'}
onClick={() =>
isRemoteDownloading && status === 'in_progress'
? cancelDownload()
: download(input)
}
disabled={
isCancelling || (status === 'in_progress' && !isRemoteDownloading)
}
>
{t('download.ok')}
{isRemoteDownloading && status === 'in_progress'
? t('download.cancel')
: t('download.ok')}
</Button>
</div>
</div>
<div>
<div className="pb-1 text-neutral-500">{t('download.inputfile')}</div>
<div className="flex items-center space-x-1">
<div className="mb-1 text-neutral-500">{t('download.sha256')}</div>
<Input
value={sha256sum}
onChange={handleSha256Change}
disabled={status === 'in_progress'}
maxLength={64}
placeholder={t('download.sha256Placeholder')}
/>
</div>
<div>
<div className="mb-1 text-neutral-500">{t('download.inputfile')}</div>
<div className="flex items-center gap-1">
<div
className={clsx(
"flex flex-col items-center justify-center w-full h-10 border-2 border-solid rounded-xl transition css-9118ya ant-input-outlined",
isDragging ? "bg-neutral-500 border-blue-500" : "",
status === "in_progress" ? "opacity-50 cursor-not-allowed pointer-events-none border-neutral-600 bg-neutral-700" : "cursor-pointer hover:bg-neutral-500"
)}
className={clsx(
'flex h-10 min-w-0 flex-1 flex-col items-center justify-center rounded-xl border-2 border-solid transition',
isDragging ? 'border-blue-500 bg-neutral-500' : 'border-neutral-600',
status === 'in_progress'
? 'cursor-not-allowed bg-neutral-700 opacity-50'
: 'cursor-pointer hover:bg-neutral-500'
)}
onDrop={(e) => {
if (status === "in_progress") return; // deaktiviert
e.preventDefault();
@@ -232,7 +397,7 @@ export const DownloadImage = () => {
document.getElementById("file-upload")?.click()
}}
>
<span className="text-neutral-100 text-sm p-1">
<span className="w-full truncate px-2 text-center text-sm text-neutral-100">
{selectedFile ? selectedFile.name : t('download.uploadbox')}
</span>
@@ -246,7 +411,7 @@ export const DownloadImage = () => {
</div>
<Button
type="primary"
className="h-10 border-2"
className="h-10 w-16 shrink-0 border-2 px-0"
onClick={() => upload(selectedFile)}
disabled={status === 'in_progress' || !selectedFile}
>
@@ -254,14 +419,16 @@ export const DownloadImage = () => {
</Button>
</div>
</div>
</>
</div>
)}
<div className={clsx('py-2')}>
<div className={clsx('min-h-8 pt-2')}>
{status && (
<div
className={clsx(
'max-w-[300px] break-words text-sm',
status === 'failed' ? 'text-red-500' : 'text-green-500'
status === 'failed' || status === 'checksum_failed'
? 'text-red-500'
: 'text-green-500'
)}
>
{log}

View File

@@ -14,6 +14,8 @@ import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage.ts';
import { client } from '@/lib/websocket.ts';
const imageUpdatedEvent = 'nanokvm:image-updated';
type ImagesProps = {
isOpen: boolean;
cdrom: boolean;
@@ -33,9 +35,18 @@ export const Images = ({ isOpen, cdrom, setIsMounted }: ImagesProps) => {
const [deletingImage, setDeletingImage] = useState('');
useEffect(() => {
if (isOpen) {
if (!isOpen) return;
getImages();
const handleImageUpdated = () => {
getImages();
}
};
window.addEventListener(imageUpdatedEvent, handleImageUpdated);
return () => {
window.removeEventListener(imageUpdatedEvent, handleImageUpdated);
};
}, [isOpen]);
// get image list