From 0a772005dc1508c7e4f3f174d24f45e290058d83 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Wed, 1 Jan 2025 22:34:59 +0000 Subject: [PATCH] Add extracting and validating the plugin --- internal/plugin/database.go | 54 ++++++ internal/plugin/extract.go | 83 +++++++++ internal/plugin/plugin.go | 122 +++++++++++++- internal/plugin/type.go | 30 ++++ jsonrpc.go | 2 + ui/src/components/UploadPluginDialog.tsx | 205 ++++++++++++++++++++++- ui/src/components/sidebar/settings.tsx | 7 +- ui/src/hooks/stores.ts | 27 ++- 8 files changed, 518 insertions(+), 12 deletions(-) create mode 100644 internal/plugin/database.go create mode 100644 internal/plugin/extract.go create mode 100644 internal/plugin/type.go diff --git a/internal/plugin/database.go b/internal/plugin/database.go new file mode 100644 index 0000000..d5d6f60 --- /dev/null +++ b/internal/plugin/database.go @@ -0,0 +1,54 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" +) + +const databaseFile = pluginsFolder + "/plugins.json" + +var pluginDatabase = PluginDatabase{} + +func init() { + if err := pluginDatabase.Load(); err != nil { + fmt.Printf("failed to load plugin database: %v\n", err) + } +} + +func (d *PluginDatabase) Load() error { + file, err := os.Open(databaseFile) + if os.IsNotExist(err) { + d.Plugins = make(map[string]PluginInstall) + return nil + } + if err != nil { + return fmt.Errorf("failed to open plugin database: %v", err) + } + defer file.Close() + + if err := json.NewDecoder(file).Decode(d); err != nil { + return fmt.Errorf("failed to decode plugin database: %v", err) + } + + return nil +} + +func (d *PluginDatabase) Save() error { + d.saveMutex.Lock() + defer d.saveMutex.Unlock() + + file, err := os.Create(databaseFile) + if err != nil { + return fmt.Errorf("failed to create plugin database: %v", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(d); err != nil { + return fmt.Errorf("failed to encode plugin database: %v", err) + } + + return nil +} diff --git a/internal/plugin/extract.go b/internal/plugin/extract.go new file mode 100644 index 0000000..45508e9 --- /dev/null +++ b/internal/plugin/extract.go @@ -0,0 +1,83 @@ +package plugin + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +const pluginsExtractsFolder = pluginsFolder + "/extracts" + +func init() { + _ = os.MkdirAll(pluginsExtractsFolder, 0755) +} + +func extractPlugin(filePath string) (*string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file for extraction: %v", err) + } + defer file.Close() + + var reader io.Reader = file + // TODO: there's probably a better way of doing this without relying on the file extension + if strings.HasSuffix(filePath, ".gz") { + gzipReader, err := gzip.NewReader(file) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %v", err) + } + defer gzipReader.Close() + reader = gzipReader + } + + destinationFolder := path.Join(pluginsExtractsFolder, uuid.New().String()) + if err := os.MkdirAll(destinationFolder, 0755); err != nil { + return nil, fmt.Errorf("failed to create extracts folder: %v", err) + } + + tarReader := tar.NewReader(reader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("failed to read tar header: %v", err) + } + + // Prevent path traversal attacks + targetPath := filepath.Join(destinationFolder, header.Name) + if !strings.HasPrefix(targetPath, filepath.Clean(destinationFolder)+string(os.PathSeparator)) { + return nil, fmt.Errorf("tar file contains illegal path: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { + return nil, fmt.Errorf("failed to create directory: %v", err) + } + case tar.TypeReg: + file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode)) + if err != nil { + return nil, fmt.Errorf("failed to create file: %v", err) + } + defer file.Close() + + if _, err := io.Copy(file, tarReader); err != nil { + return nil, fmt.Errorf("failed to extract file: %v", err) + } + default: + return nil, fmt.Errorf("unsupported tar entry type: %v", header.Typeflag) + } + } + + return &destinationFolder, nil +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index a794bba..3a3318d 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -1,6 +1,7 @@ package plugin import ( + "encoding/json" "fmt" "kvm/internal/storage" "os" @@ -10,7 +11,7 @@ import ( ) const pluginsFolder = "/userdata/jetkvm/plugins" -const pluginsUploadFolder = pluginsFolder + "/_uploads" +const pluginsUploadFolder = pluginsFolder + "/uploads" func init() { _ = os.MkdirAll(pluginsUploadFolder, 0755) @@ -51,3 +52,122 @@ func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUplo DataChannel: uploadId, }, nil } + +func RpcPluginExtract(filename string) (*PluginManifest, error) { + sanitizedFilename, err := storage.SanitizeFilename(filename) + if err != nil { + return nil, err + } + + filePath := path.Join(pluginsUploadFolder, sanitizedFilename) + extractFolder, err := extractPlugin(filePath) + if err != nil { + return nil, err + } + + if err := os.Remove(filePath); err != nil { + return nil, fmt.Errorf("failed to delete uploaded file: %v", err) + } + + manifest, err := readManifest(*extractFolder) + if err != nil { + return nil, err + } + + // Get existing PluginInstall + install, ok := pluginDatabase.Plugins[manifest.Name] + if !ok { + install = PluginInstall{ + Enabled: false, + Version: manifest.Version, + ExtractedVersions: make(map[string]string), + } + } + + _, ok = install.ExtractedVersions[manifest.Version] + if ok { + return nil, fmt.Errorf("this version has already been uploaded: %s", manifest.Version) + } + + install.ExtractedVersions[manifest.Version] = *extractFolder + pluginDatabase.Plugins[manifest.Name] = install + + if err := pluginDatabase.Save(); err != nil { + return nil, fmt.Errorf("failed to save plugin database: %v", err) + } + + return manifest, nil +} + +func RpcPluginInstall(name string, version string) error { + // TODO: find the plugin version in the plugins.json file + pluginInstall, ok := pluginDatabase.Plugins[name] + if !ok { + return fmt.Errorf("plugin not found: %s", name) + } + + if pluginInstall.Version == version && pluginInstall.Enabled { + fmt.Printf("Plugin %s is already installed with version %s\n", name, version) + return nil + } + + _, ok = pluginInstall.ExtractedVersions[version] + if !ok { + return fmt.Errorf("plugin version not found: %s", version) + } + + // TODO: If there is a running plugin with the same name, stop it and start the new version + + pluginInstall.Version = version + pluginInstall.Enabled = true + pluginDatabase.Plugins[name] = pluginInstall + + if err := pluginDatabase.Save(); err != nil { + return fmt.Errorf("failed to save plugin database: %v", err) + } + // TODO: start the plugin + + // TODO: Determine if the old version should be removed + + return nil +} + +func readManifest(extractFolder string) (*PluginManifest, error) { + manifestPath := path.Join(extractFolder, "manifest.json") + manifestFile, err := os.Open(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to open manifest file: %v", err) + } + defer manifestFile.Close() + + manifest := PluginManifest{} + if err := json.NewDecoder(manifestFile).Decode(&manifest); err != nil { + return nil, fmt.Errorf("failed to read manifest file: %v", err) + } + + if err := validateManifest(&manifest); err != nil { + return nil, fmt.Errorf("invalid manifest file: %v", err) + } + + return &manifest, nil +} + +func validateManifest(manifest *PluginManifest) error { + if manifest.ManifestVersion != "1" { + return fmt.Errorf("unsupported manifest version: %s", manifest.ManifestVersion) + } + + if manifest.Name == "" { + return fmt.Errorf("missing plugin name") + } + + if manifest.Version == "" { + return fmt.Errorf("missing plugin version") + } + + if manifest.Homepage == "" { + return fmt.Errorf("missing plugin homepage") + } + + return nil +} diff --git a/internal/plugin/type.go b/internal/plugin/type.go new file mode 100644 index 0000000..6f07c59 --- /dev/null +++ b/internal/plugin/type.go @@ -0,0 +1,30 @@ +package plugin + +import "sync" + +type PluginManifest struct { + ManifestVersion string `json:"manifest_version"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Homepage string `json:"homepage"` + BinaryPath string `json:"bin"` + SystemMinVersion string `json:"system_min_version,omitempty"` +} + +type PluginInstall struct { + Enabled bool `json:"enabled"` + + // Current active version of the plugin + Version string `json:"version"` + + // Map of a plugin version to the extracted directory + ExtractedVersions map[string]string `json:"extracted_versions"` +} + +type PluginDatabase struct { + // Map with the plugin name as the key + Plugins map[string]PluginInstall `json:"plugins"` + + saveMutex sync.Mutex +} diff --git a/jsonrpc.go b/jsonrpc.go index 34d6ef9..6ffdd87 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -556,4 +556,6 @@ var rpcHandlers = map[string]RPCHandler{ "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, "resetConfig": {Func: rpcResetConfig}, "pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}}, + "pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}}, + "pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}}, } diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx index 5fb950f..5cbd4ad 100644 --- a/ui/src/components/UploadPluginDialog.tsx +++ b/ui/src/components/UploadPluginDialog.tsx @@ -5,6 +5,8 @@ import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg"; import Modal from "@components/Modal"; import { + PluginManifest, + usePluginStore, useRTCStore, } from "../hooks/stores"; import { cx } from "../cva.config"; @@ -16,6 +18,7 @@ import { formatters } from "@/utils"; import { PlusCircleIcon } from "@heroicons/react/20/solid"; import AutoHeight from "./AutoHeight"; import { useJsonRpc } from "../hooks/useJsonRpc"; +import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import notifications from "../notifications"; import { isOnDevice } from "../main"; import { ViewHeader } from "./MountMediaDialog"; @@ -35,6 +38,28 @@ export default function UploadPluginModal({ } function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { + const { + pluginUploadModalView, + setPluginUploadModalView, + pluginUploadFilename, + setPluginUploadFilename, + pluginUploadManifest, + setPluginUploadManifest, + } = usePluginStore(); + const [send] = useJsonRpc(); + const [extractError, setExtractError] = useState(null); + + function extractPlugin(filename: string) { + send("pluginExtract", { filename }, resp => { + if ("error" in resp) { + setExtractError(resp.error.data || resp.error.message); + return + } + + setPluginUploadManifest(resp.result as PluginManifest); + }); + } + return (
void }) { className="h-[24px] dark:block hidden dark:!mt-0" /> - { setOpen(false) }} - onCancelUpload={() => { - setOpen(false) + onUploadCompleted={(filename) => { + setPluginUploadFilename(filename) + setPluginUploadModalView("install") + extractPlugin(filename) }} - /> + />} + + {extractError && ( + { + setOpen(false) + setPluginUploadFilename(null) + setExtractError(null) + }} + onRetry={() => { + setExtractError(null) + setPluginUploadFilename(null) + setPluginUploadModalView("upload") + }} + /> + )} + + {!extractError && pluginUploadModalView === "install" && { + setOpen(false) + setPluginUploadFilename(null) + // TODO: Open plugin settings dialog + }} + onBack={() => { + setPluginUploadModalView("upload") + setPluginUploadFilename(null) + }} + />}
@@ -74,10 +131,10 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { // TODO: refactor to a shared component function UploadFileView({ onBack, - onCancelUpload, + onUploadCompleted, }: { onBack: () => void; - onCancelUpload: () => void; + onUploadCompleted: (filename: string) => void; }) { const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">( "idle", @@ -177,6 +234,7 @@ function UploadFileView({ if (offset >= file.size) { rtcDataChannel.close(); setUploadState("success"); + onUploadCompleted(file.name); return; } @@ -260,6 +318,7 @@ function UploadFileView({ xhr.onload = () => { if (xhr.status === 200) { setUploadState("success"); + onUploadCompleted(file.name); } else { console.error("Upload error:", xhr.statusText); setUploadError(xhr.statusText); @@ -459,7 +518,7 @@ function UploadFileView({ theme="light" text="Cancel Upload" onClick={() => { - onCancelUpload(); + onBack(); setUploadState("idle"); setUploadProgress(0); setUploadedFileName(null); @@ -470,7 +529,7 @@ function UploadFileView({ ) : (