diff --git a/server/router/download.go b/server/router/download.go new file mode 100644 index 0000000..16e9db2 --- /dev/null +++ b/server/router/download.go @@ -0,0 +1,17 @@ +package router + +import ( + "NanoKVM-Server/service/download" + "github.com/gin-gonic/gin" + + "NanoKVM-Server/middleware" +) + +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 +} diff --git a/server/router/router.go b/server/router/router.go index 6bb4322..2c0e918 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -37,4 +37,5 @@ func server(r *gin.Engine) { networkRouter(r) hidRouter(r) wsRouter(r) + downloadRouter(r) } diff --git a/server/service/download/service.go b/server/service/download/service.go new file mode 100644 index 0000000..c68737b --- /dev/null +++ b/server/service/download/service.go @@ -0,0 +1,198 @@ +package download + +import ( + "NanoKVM-Server/proto" + "fmt" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +type Service struct{} + +var sentinelPath = "/tmp/.download_in_progress" + +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{} +} + +func (s *Service) ImageEnabled(c *gin.Context) { + // 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) { + c.JSON(http.StatusOK, gin.H{"enabled": false}) + } + c.JSON(http.StatusOK, gin.H{"enabled": false}) // Other error + } + + c.JSON(http.StatusOK, gin.H{"enabled": true}) +} + +func (s *Service) StatusImage(c *gin.Context) { + // 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") + c.JSON(http.StatusOK, gin.H{"status": "in_progress", "file": ""}) + return + } + splitted := strings.Split(string(content), ";") + if len(splitted) == 1 { + // No percentage, just the URL + c.JSON(http.StatusOK, gin.H{"status": "in_progress", "file": splitted[0]}) + } else { + // Percentage is available + c.JSON(http.StatusOK, gin.H{"status": "in_progress", "file": splitted[0], "percentage": splitted[1]}) + } + + return + } + c.JSON(http.StatusOK, gin.H{"status": "idle"}) +} + +func (s *Service) DownloadImage(c *gin.Context) { + var req proto.MountImageReq + var rsp proto.Response + + log.Debug("DownloadImage") + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -1, "invalid arguments") + return + } + + if req.File == "" { + rsp.ErrRsp(c, -1, "invalid arguments") + return + } + // Parse the URI to see if its valid http/s + u, err := url.Parse(req.File) + if err != nil || u.Scheme == "" || u.Host == "" { + 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) + if err != nil { + log.Error("Failed to create sentinel file") + rsp.ErrRsp(c, -1, "failed to create sentinel file") + 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() + + 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() + return + } + lw.stopTicker() + }() + c.JSON(http.StatusOK, gin.H{"status": "in_progress", "file": req.File}) +} + +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/web/src/api/download.ts b/web/src/api/download.ts new file mode 100644 index 0000000..6ae3cf5 --- /dev/null +++ b/web/src/api/download.ts @@ -0,0 +1,17 @@ +import { http } from '@/lib/http.ts'; + +// Download image +export function downloadImage(file?: string) { + const data = { + file: file ? file : '' + }; + return http.post('/api/download/image', data); +} + +export function statusImage() { + return http.get('/api/download/image/status'); +} + +export function imageEnabled() { + return http.get('/api/download/image/enabled'); +} diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 4189703..9fb2fe2 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -143,6 +143,12 @@ const en = { powerShort: 'Power (short click)', powerLong: 'Power (long click)' }, + download: { + download: 'Download Image', + input: 'Please enter a remote image URL', + ok: 'Ok', + disabled: '/data partition is RO, so we cannot download the image', + }, settings: { title: 'Settings', about: { diff --git a/web/src/pages/desktop/menu/download.tsx b/web/src/pages/desktop/menu/download.tsx new file mode 100644 index 0000000..27e15a8 --- /dev/null +++ b/web/src/pages/desktop/menu/download.tsx @@ -0,0 +1,163 @@ +import { ChangeEvent, useEffect, useRef, useState } from 'react'; +import { Button, Divider, Input, List, Popover } from 'antd'; +import type { InputRef } from 'antd'; +import clsx from 'clsx'; +import { useSetAtom } from 'jotai'; +import { DownloadIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { downloadImage, statusImage, imageEnabled } from '@/api/download.ts'; +import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts'; + +export const DownloadImage = () => { + const { t } = useTranslation(); + const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const [input, setInput] = useState(''); + const [status, setStatus] = useState(''); + const [log, setLog] = useState(''); + const [diskEnabled, setDiskEnabled] = useState(false); + const [popoverKey, setPopoverKey] = useState(0); + + + const inputRef = useRef(null); + + const intervalId = useRef(null); + + + useEffect(() => { + checkDiskEnabled(); + }, []); + + function checkDiskEnabled() { + imageEnabled() + .then((res) => { + console.log(res.enabled); + setDiskEnabled(res.enabled); + }) + .catch((err) => { + setDiskEnabled(false); + }); + } + function handleOpenChange(open: boolean) { + if (open) { + clearInterval(intervalId); + checkDiskEnabled(); + getDownloadStatus(); + if (!intervalId.current) { + intervalId.current = setInterval(getDownloadStatus, 2500); + }; + setIsKeyboardEnable(false); + setPopoverKey(prevKey => prevKey + 1); // Force re-render + } else { + setInput(''); + setStatus(''); + setLog(''); + + setIsKeyboardEnable(true); + clearInterval(intervalId.current); + intervalId.current = undefined; + } + + setIsPopoverOpen(open); + } + + function handleChange(e: ChangeEvent) { + setInput(e.target.value); + } + + function getDownloadStatus() { + statusImage().then((rsp) => { + if (rsp.status) { + setStatus(rsp.status); + if (rsp.status === 'in_progress') { + // Check if rsp has a percentage value + if (rsp.percentage) { + setLog('Downloading ('+ rsp.percentage + ')' + ': ' + rsp.file); + } else { + setLog('Downloading' + ': ' + rsp.file); + } + setInput(rsp.file); + }; + if (rsp.status === 'failed') { + setLog('Failed'); + clearInterval(intervalId.current); + }; + if (rsp.status === 'idle') { + setLog(''); // Clear the log + clearInterval(intervalId.current); + }; + }; + }); + }; + + function download(url?: string) { + setStatus('in_progress'); + setLog('Downloading: ' + url); + // start the getDownloadStatus to tick every 5 seconds + + downloadImage(url).then((rsp) => { + getDownloadStatus(); + // Start the interval to check the download status + if (!intervalId.current) { + intervalId.current = setInterval(getDownloadStatus, 2500);} + }).catch((err) => { + clearInterval(intervalId.current); // Clear the interval when the download is complete or fails + setStatus('failed'); + setLog('Failed'); + }); + } + + const content = ( +
+
+ {t('download.download')} +
+ + + + {!diskEnabled ? ( +
{t('download.disabled')}
+ ) : ( + <> +
{t('download.input')}
+
+ + +
+ + )} +
+ {status && ( +
+ {log} +
+ )} +
+
+ ); + + return ( + +
+ +
+
+ ); +}; diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index e9936a4..e681af3 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -16,6 +16,7 @@ import { Screen } from './screen'; import { Script } from './script'; import { Settings } from './settings'; import { Terminal } from './terminal'; +import { DownloadImage } from './download.tsx'; import { Wol } from './wol'; export const Menu = () => { @@ -50,6 +51,7 @@ export const Menu = () => { {!menuDisabledItems.includes('script') &&