mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
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
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func downloadRouter(r *gin.Engine) {
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
if err != nil {
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: false})
|
||||
return
|
||||
}
|
||||
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,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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,78 +186,46 @@ 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!)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
var lw *loggingWriter
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
@@ -157,131 +234,93 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
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" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
filename := part.FileName()
|
||||
if filename == "" {
|
||||
log.Error("no filename")
|
||||
rsp.ErrRsp(c, -1, "no filename")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
if err := validateISOFilename(filename); err != nil {
|
||||
_ = part.Close()
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
s.setDownloadFile(done, filename)
|
||||
|
||||
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 !strings.HasSuffix(strings.ToLower(filename), ".iso") {
|
||||
rsp.ErrRsp(c, -1, "only .iso files allowed")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
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()
|
||||
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 {
|
||||
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 {
|
||||
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")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
lw.stopTicker()
|
||||
|
||||
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: "idle",
|
||||
Status: string(downloadStatusIdle),
|
||||
File: "",
|
||||
Percentage: "",
|
||||
})
|
||||
|
||||
defer os.Remove(sentinelPath)
|
||||
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
|
||||
}
|
||||
lw.stopTicker()
|
||||
|
||||
log.Errorf("Failed to download image: %v", err)
|
||||
status := downloadStatusFailed
|
||||
if errors.Is(err, errSHA256Mismatch) {
|
||||
status = downloadStatusChecksumFailed
|
||||
}
|
||||
s.finishDownload(done, status)
|
||||
return
|
||||
}
|
||||
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
|
||||
total atomic.Int64
|
||||
totalSize int64
|
||||
ticker *time.Ticker
|
||||
done chan bool
|
||||
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() {
|
||||
if lw == nil {
|
||||
return
|
||||
}
|
||||
lw.stopOnce.Do(func() {
|
||||
lw.ticker.Stop()
|
||||
lw.done <- true
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -242,7 +242,15 @@ const zh = {
|
||||
disabled: '/data 是只读分区,无法下载镜像',
|
||||
uploadbox: '将文件拖放到此处或单击选择',
|
||||
inputfile: '请输入图片文件',
|
||||
NoISO: '无 ISO'
|
||||
NoISO: '无 ISO',
|
||||
sha256: 'SHA-256(可选)',
|
||||
sha256Placeholder: '请输入 64 位 SHA-256 校验和',
|
||||
invalidSHA256: 'SHA-256 必须是 64 位十六进制字符串',
|
||||
failed: '下载失败',
|
||||
success: '下载成功',
|
||||
checksumFailed: '下载失败:SHA-256 校验失败',
|
||||
cancel: '取消',
|
||||
cancelFailed: '取消下载失败'
|
||||
},
|
||||
power: {
|
||||
title: '电源',
|
||||
|
||||
@@ -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 {
|
||||
setIsKeyboardEnable(true);
|
||||
|
||||
// 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('');
|
||||
|
||||
setIsKeyboardEnable(true);
|
||||
clearInterval(intervalId.current);
|
||||
intervalId.current = undefined;
|
||||
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();
|
||||
|
||||
}
|
||||
|
||||
@@ -187,13 +329,32 @@ export const DownloadImage = () => {
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => download(input)}
|
||||
disabled={status === 'in_progress'}
|
||||
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 className="mt-2">
|
||||
<div className="pb-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="pb-1 text-neutral-500">{t('download.inputfile')}</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
@@ -261,7 +422,9 @@ export const DownloadImage = () => {
|
||||
<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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user