diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 0000000..a794bba --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,53 @@ +package plugin + +import ( + "fmt" + "kvm/internal/storage" + "os" + "path" + + "github.com/google/uuid" +) + +const pluginsFolder = "/userdata/jetkvm/plugins" +const pluginsUploadFolder = pluginsFolder + "/_uploads" + +func init() { + _ = os.MkdirAll(pluginsUploadFolder, 0755) +} + +func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUpload, error) { + sanitizedFilename, err := storage.SanitizeFilename(filename) + if err != nil { + return nil, err + } + + filePath := path.Join(pluginsUploadFolder, sanitizedFilename) + uploadPath := filePath + ".incomplete" + + if _, err := os.Stat(filePath); err == nil { + return nil, fmt.Errorf("file already exists: %s", sanitizedFilename) + } + + var alreadyUploadedBytes int64 = 0 + if stat, err := os.Stat(uploadPath); err == nil { + alreadyUploadedBytes = stat.Size() + } + + uploadId := "plugin_" + uuid.New().String() + file, err := os.OpenFile(uploadPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("failed to open file for upload: %v", err) + } + + storage.AddPendingUpload(uploadId, storage.PendingUpload{ + File: file, + Size: size, + AlreadyUploadedBytes: alreadyUploadedBytes, + }) + + return &storage.StorageFileUpload{ + AlreadyUploadedBytes: alreadyUploadedBytes, + DataChannel: uploadId, + }, nil +} diff --git a/internal/storage/type.go b/internal/storage/type.go new file mode 100644 index 0000000..ba7a123 --- /dev/null +++ b/internal/storage/type.go @@ -0,0 +1,6 @@ +package storage + +type StorageFileUpload struct { + AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"` + DataChannel string `json:"dataChannel"` +} diff --git a/internal/storage/uploads.go b/internal/storage/uploads.go new file mode 100644 index 0000000..48fdaf7 --- /dev/null +++ b/internal/storage/uploads.go @@ -0,0 +1,34 @@ +package storage + +import ( + "os" + "sync" +) + +type PendingUpload struct { + File *os.File + Size int64 + AlreadyUploadedBytes int64 +} + +var pendingUploads = make(map[string]PendingUpload) +var pendingUploadsMutex sync.Mutex + +func GetPendingUpload(uploadId string) (PendingUpload, bool) { + pendingUploadsMutex.Lock() + defer pendingUploadsMutex.Unlock() + upload, ok := pendingUploads[uploadId] + return upload, ok +} + +func AddPendingUpload(uploadId string, upload PendingUpload) { + pendingUploadsMutex.Lock() + defer pendingUploadsMutex.Unlock() + pendingUploads[uploadId] = upload +} + +func DeletePendingUpload(uploadId string) { + pendingUploadsMutex.Lock() + defer pendingUploadsMutex.Unlock() + delete(pendingUploads, uploadId) +} diff --git a/internal/storage/utils.go b/internal/storage/utils.go new file mode 100644 index 0000000..e622fc2 --- /dev/null +++ b/internal/storage/utils.go @@ -0,0 +1,19 @@ +package storage + +import ( + "errors" + "path/filepath" + "strings" +) + +func SanitizeFilename(filename string) (string, error) { + cleanPath := filepath.Clean(filename) + if filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..") { + return "", errors.New("invalid filename") + } + sanitized := filepath.Base(cleanPath) + if sanitized == "." || sanitized == string(filepath.Separator) { + return "", errors.New("invalid filename") + } + return sanitized, nil +} diff --git a/jsonrpc.go b/jsonrpc.go index 2ce5f18..34d6ef9 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "kvm/internal/plugin" "log" "os" "os/exec" @@ -554,4 +555,5 @@ var rpcHandlers = map[string]RPCHandler{ "getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices}, "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, "resetConfig": {Func: rpcResetConfig}, + "pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}}, } diff --git a/ui/src/components/MountMediaDialog.tsx b/ui/src/components/MountMediaDialog.tsx index 505d233..8deb4a5 100644 --- a/ui/src/components/MountMediaDialog.tsx +++ b/ui/src/components/MountMediaDialog.tsx @@ -1516,7 +1516,7 @@ function PreUploadedImageItem({ ); } -function ViewHeader({ title, description }: { title: string; description: string }) { +export function ViewHeader({ title, description }: { title: string; description: string }) { return (

diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx new file mode 100644 index 0000000..5fb950f --- /dev/null +++ b/ui/src/components/UploadPluginDialog.tsx @@ -0,0 +1,482 @@ +import Card, { GridCard } from "@/components/Card"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@components/Button"; +import LogoBlueIcon from "@/assets/logo-blue.svg"; +import LogoWhiteIcon from "@/assets/logo-white.svg"; +import Modal from "@components/Modal"; +import { + useRTCStore, +} from "../hooks/stores"; +import { cx } from "../cva.config"; +import { + LuCheck, + LuUpload, +} from "react-icons/lu"; +import { formatters } from "@/utils"; +import { PlusCircleIcon } from "@heroicons/react/20/solid"; +import AutoHeight from "./AutoHeight"; +import { useJsonRpc } from "../hooks/useJsonRpc"; +import notifications from "../notifications"; +import { isOnDevice } from "../main"; +import { ViewHeader } from "./MountMediaDialog"; + +export default function UploadPluginModal({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + setOpen(false)}> + + + ); +} + +function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { + return ( + +
+ +
+
+ JetKVM Logo + JetKVM Logo + + { + setOpen(false) + }} + onCancelUpload={() => { + setOpen(false) + }} + /> +
+
+
+
+
+ ); +} + +// This is pretty much a copy-paste from the UploadFileView component in the MountMediaDialog just with the media terminology changed and the rpc method changed. +// TODO: refactor to a shared component +function UploadFileView({ + onBack, + onCancelUpload, +}: { + onBack: () => void; + onCancelUpload: () => void; +}) { + const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">( + "idle", + ); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadedFileName, setUploadedFileName] = useState(null); + const [uploadedFileSize, setUploadedFileSize] = useState(null); + const [uploadSpeed, setUploadSpeed] = useState(null); + const [fileError, setFileError] = useState(null); + const [uploadError, setUploadError] = useState(null); + + const [send] = useJsonRpc(); + const rtcDataChannelRef = useRef(null); + + useEffect(() => { + const ref = rtcDataChannelRef.current; + return () => { + if (ref) { + ref.onopen = null; + ref.onerror = null; + ref.onmessage = null; + ref.onclose = null; + ref.close(); + } + }; + }, []); + + function handleWebRTCUpload( + file: File, + alreadyUploadedBytes: number, + dataChannel: string, + ) { + const rtcDataChannel = useRTCStore + .getState() + .peerConnection?.createDataChannel(dataChannel); + + if (!rtcDataChannel) { + console.error("Failed to create data channel for file upload"); + notifications.error("Failed to create data channel for file upload"); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + + return; + } + + rtcDataChannelRef.current = rtcDataChannel; + + const lowWaterMark = 256 * 1024; + const highWaterMark = 1 * 1024 * 1024; + rtcDataChannel.bufferedAmountLowThreshold = lowWaterMark; + + let lastUploadedBytes = alreadyUploadedBytes; + let lastUpdateTime = Date.now(); + const speedHistory: number[] = []; + + rtcDataChannel.onmessage = e => { + try { + const { AlreadyUploadedBytes, Size } = JSON.parse(e.data) as { + AlreadyUploadedBytes: number; + Size: number; + }; + + const now = Date.now(); + const timeDiff = (now - lastUpdateTime) / 1000; // in seconds + const bytesDiff = AlreadyUploadedBytes - lastUploadedBytes; + + if (timeDiff > 0) { + const instantSpeed = bytesDiff / timeDiff; // bytes per second + + // Add to speed history, keeping last 5 readings + speedHistory.push(instantSpeed); + if (speedHistory.length > 5) { + speedHistory.shift(); + } + + // Calculate average speed + const averageSpeed = + speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; + + setUploadSpeed(averageSpeed); + setUploadProgress((AlreadyUploadedBytes / Size) * 100); + } + + lastUploadedBytes = AlreadyUploadedBytes; + lastUpdateTime = now; + } catch (e) { + console.error("Error processing RTC Data channel message:", e); + } + }; + + rtcDataChannel.onopen = () => { + let pauseSending = false; // Pause sending when the buffered amount is high + const chunkSize = 4 * 1024; // 4KB chunks + + let offset = alreadyUploadedBytes; + const sendNextChunk = () => { + if (offset >= file.size) { + rtcDataChannel.close(); + setUploadState("success"); + return; + } + + if (pauseSending) return; + + const chunk = file.slice(offset, offset + chunkSize); + chunk.arrayBuffer().then(buffer => { + rtcDataChannel.send(buffer); + + if (rtcDataChannel.bufferedAmount >= highWaterMark) { + pauseSending = true; + } + + offset += buffer.byteLength; + console.log(`Chunk sent: ${offset} / ${file.size} bytes`); + sendNextChunk(); + }); + }; + + sendNextChunk(); + rtcDataChannel.onbufferedamountlow = () => { + console.log("RTC Data channel buffered amount low"); + pauseSending = false; // Now the data channel is ready to send more data + sendNextChunk(); + }; + }; + + rtcDataChannel.onerror = error => { + console.error("RTC Data channel error:", error); + notifications.error(`Upload failed: ${error}`); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + }; + } + + async function handleHttpUpload( + file: File, + alreadyUploadedBytes: number, + dataChannel: string, + ) { + const uploadUrl = `${import.meta.env.VITE_SIGNAL_API}/storage/upload?uploadId=${dataChannel}`; + + const xhr = new XMLHttpRequest(); + xhr.open("POST", uploadUrl, true); + + let lastUploadedBytes = alreadyUploadedBytes; + let lastUpdateTime = Date.now(); + const speedHistory: number[] = []; + + xhr.upload.onprogress = event => { + if (event.lengthComputable) { + const totalUploaded = alreadyUploadedBytes + event.loaded; + const totalSize = file.size; + + const now = Date.now(); + const timeDiff = (now - lastUpdateTime) / 1000; // in seconds + const bytesDiff = totalUploaded - lastUploadedBytes; + + if (timeDiff > 0) { + const instantSpeed = bytesDiff / timeDiff; // bytes per second + + // Add to speed history, keeping last 5 readings + speedHistory.push(instantSpeed); + if (speedHistory.length > 5) { + speedHistory.shift(); + } + + // Calculate average speed + const averageSpeed = + speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; + + setUploadSpeed(averageSpeed); + setUploadProgress((totalUploaded / totalSize) * 100); + } + + lastUploadedBytes = totalUploaded; + lastUpdateTime = now; + } + }; + + xhr.onload = () => { + if (xhr.status === 200) { + setUploadState("success"); + } else { + console.error("Upload error:", xhr.statusText); + setUploadError(xhr.statusText); + setUploadState("idle"); + } + }; + + xhr.onerror = () => { + console.error("XHR error:", xhr.statusText); + setUploadError(xhr.statusText); + setUploadState("idle"); + }; + + // Prepare the data to send + const blob = file.slice(alreadyUploadedBytes); + + // Send the file data + xhr.send(blob); + } + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + // Reset the upload error when a new file is selected + setUploadError(null); + + setFileError(null); + console.log(`File selected: ${file.name}, size: ${file.size} bytes`); + setUploadedFileName(file.name); + setUploadedFileSize(file.size); + setUploadState("uploading"); + console.log("Upload state set to 'uploading'"); + + send("pluginStartUpload", { filename: file.name, size: file.size }, resp => { + console.log("pluginStartUpload response:", resp); + if ("error" in resp) { + console.error("Upload error:", resp.error.message); + setUploadError(resp.error.data || resp.error.message); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + return; + } + + const { alreadyUploadedBytes, dataChannel } = resp.result as { + alreadyUploadedBytes: number; + dataChannel: string; + }; + + console.log( + `Already uploaded bytes: ${alreadyUploadedBytes}, Data channel: ${dataChannel}`, + ); + + if (isOnDevice) { + handleHttpUpload(file, alreadyUploadedBytes, dataChannel); + } else { + handleWebRTCUpload(file, alreadyUploadedBytes, dataChannel); + } + }); + } + }; + + return ( +
+ +
+
{ + if (uploadState === "idle") { + document.getElementById("file-upload")?.click(); + } + }} + className="block select-none" + > +
+ +
+
+ {uploadState === "idle" && ( +
+
+ +
+ +
+
+
+

+ Click to select a file +

+

+ Supported formats: TAR, TAR.GZ +

+
+ )} + + {uploadState === "uploading" && ( +
+
+ +
+ +
+
+
+

+ Uploading {formatters.truncateMiddle(uploadedFileName, 30)} +

+

+ {formatters.bytes(uploadedFileSize || 0)} +

+
+
+
+
+
+ Uploading... + + {uploadSpeed !== null + ? `${formatters.bytes(uploadSpeed)}/s` + : "Calculating..."} + +
+
+
+ )} + + {uploadState === "success" && ( +
+
+ +
+ +
+
+
+

+ Upload successful +

+

+ {formatters.truncateMiddle(uploadedFileName, 40)} has been + uploaded +

+
+ )} +
+
+
+
+
+ + {fileError &&

{fileError}

} +
+ + {/* Display upload error if present */} + {uploadError && ( +
+ Error: {uploadError} +
+ )} + +
+
+ {uploadState === "uploading" ? ( +
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index ec606a6..bb9439a 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -4,6 +4,7 @@ import { useSettingsStore, useUiStore, useUpdateStore, + usePluginStore, } from "@/hooks/stores"; import { Checkbox } from "@components/Checkbox"; import { Button, LinkButton } from "@components/Button"; @@ -25,6 +26,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog"; import { LocalDevice } from "@routes/devices.$id"; import { useRevalidator } from "react-router-dom"; import { ShieldCheckIcon } from "@heroicons/react/20/solid"; +import UploadPluginModal from "@components/UploadPluginDialog"; export function SettingsItem({ title, @@ -251,6 +253,8 @@ export default function SettingsSidebar() { } }; + const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore(); + useEffect(() => { getCloudState(); @@ -743,6 +747,44 @@ export default function SettingsSidebar() {
) : null} +
+ +
    +
  • +
    +
    +
    +
    +

    Tailscale

    +

    https://github.com/tutman96/jetkvm-plugin-tailscale

    +
    +
    +
    +
  • +
+
+
+
+
(set => ({ setModalView: view => set({ modalView: view }), setErrorMessage: message => set({ errorMessage: message }), })); + + +interface PluginState { + isPluginUploadModalOpen: boolean; + setIsPluginUploadModalOpen: (isOpen: boolean) => void; +} + +export const usePluginStore = create(set => ({ + isPluginUploadModalOpen: false, + setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }), +})); \ No newline at end of file diff --git a/usb_mass_storage.go b/usb_mass_storage.go index b897c20..b72ab97 100644 --- a/usb_mass_storage.go +++ b/usb_mass_storage.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "kvm/internal/storage" "kvm/resource" "log" "net/http" @@ -252,7 +253,7 @@ func rpcMountWithWebRTC(filename string, size int64, mode VirtualMediaMode) erro } func rpcMountWithStorage(filename string, mode VirtualMediaMode) error { - filename, err := sanitizeFilename(filename) + filename, err := storage.SanitizeFilename(filename) if err != nil { return err } @@ -341,20 +342,8 @@ func rpcListStorageFiles() (*StorageFiles, error) { return &StorageFiles{Files: storageFiles}, nil } -func sanitizeFilename(filename string) (string, error) { - cleanPath := filepath.Clean(filename) - if filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..") { - return "", errors.New("invalid filename") - } - sanitized := filepath.Base(cleanPath) - if sanitized == "." || sanitized == string(filepath.Separator) { - return "", errors.New("invalid filename") - } - return sanitized, nil -} - func rpcDeleteStorageFile(filename string) error { - sanitizedFilename, err := sanitizeFilename(filename) + sanitizedFilename, err := storage.SanitizeFilename(filename) if err != nil { return err } @@ -373,15 +362,10 @@ func rpcDeleteStorageFile(filename string) error { return nil } -type StorageFileUpload struct { - AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"` - DataChannel string `json:"dataChannel"` -} - const uploadIdPrefix = "upload_" -func rpcStartStorageFileUpload(filename string, size int64) (*StorageFileUpload, error) { - sanitizedFilename, err := sanitizeFilename(filename) +func rpcStartStorageFileUpload(filename string, size int64) (*storage.StorageFileUpload, error) { + sanitizedFilename, err := storage.SanitizeFilename(filename) if err != nil { return nil, err } @@ -403,28 +387,19 @@ func rpcStartStorageFileUpload(filename string, size int64) (*StorageFileUpload, if err != nil { return nil, fmt.Errorf("failed to open file for upload: %v", err) } - pendingUploadsMutex.Lock() - pendingUploads[uploadId] = pendingUpload{ + + storage.AddPendingUpload(uploadId, storage.PendingUpload{ File: file, Size: size, AlreadyUploadedBytes: alreadyUploadedBytes, - } - pendingUploadsMutex.Unlock() - return &StorageFileUpload{ + }) + + return &storage.StorageFileUpload{ AlreadyUploadedBytes: alreadyUploadedBytes, DataChannel: uploadId, }, nil } -type pendingUpload struct { - File *os.File - Size int64 - AlreadyUploadedBytes int64 -} - -var pendingUploads = make(map[string]pendingUpload) -var pendingUploadsMutex sync.Mutex - type UploadProgress struct { Size int64 AlreadyUploadedBytes int64 @@ -433,9 +408,7 @@ type UploadProgress struct { func handleUploadChannel(d *webrtc.DataChannel) { defer d.Close() uploadId := d.Label() - pendingUploadsMutex.Lock() - pendingUpload, ok := pendingUploads[uploadId] - pendingUploadsMutex.Unlock() + pendingUpload, ok := storage.GetPendingUpload(uploadId) if !ok { logger.Warnf("upload channel opened for unknown upload: %s", uploadId) return @@ -454,9 +427,7 @@ func handleUploadChannel(d *webrtc.DataChannel) { } else { logger.Warnf("uploaded ended before the complete file received") } - pendingUploadsMutex.Lock() - delete(pendingUploads, uploadId) - pendingUploadsMutex.Unlock() + storage.DeletePendingUpload(uploadId) }() uploadComplete := make(chan struct{}) lastProgressTime := time.Now() @@ -502,9 +473,7 @@ func handleUploadChannel(d *webrtc.DataChannel) { func handleUploadHttp(c *gin.Context) { uploadId := c.Query("uploadId") - pendingUploadsMutex.Lock() - pendingUpload, ok := pendingUploads[uploadId] - pendingUploadsMutex.Unlock() + pendingUpload, ok := storage.GetPendingUpload(uploadId) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "Upload not found"}) return @@ -524,9 +493,7 @@ func handleUploadHttp(c *gin.Context) { } else { logger.Warnf("uploaded ended before the complete file received") } - pendingUploadsMutex.Lock() - delete(pendingUploads, uploadId) - pendingUploadsMutex.Unlock() + storage.DeletePendingUpload(uploadId) }() reader := c.Request.Body