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<string | null>(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 ( <AutoHeight> <div @@ -54,14 +79,46 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { className="h-[24px] dark:block hidden dark:!mt-0" /> - <UploadFileView + {!extractError && pluginUploadModalView === "upload" && <UploadFileView onBack={() => { setOpen(false) }} - onCancelUpload={() => { - setOpen(false) + onUploadCompleted={(filename) => { + setPluginUploadFilename(filename) + setPluginUploadModalView("install") + extractPlugin(filename) }} - /> + />} + + {extractError && ( + <ErrorView + errorMessage={extractError} + onClose={() => { + setOpen(false) + setPluginUploadFilename(null) + setExtractError(null) + }} + onRetry={() => { + setExtractError(null) + setPluginUploadFilename(null) + setPluginUploadModalView("upload") + }} + /> + )} + + {!extractError && pluginUploadModalView === "install" && <InstallPluginView + filename={pluginUploadFilename!} + manifest={pluginUploadManifest} + onInstall={() => { + setOpen(false) + setPluginUploadFilename(null) + // TODO: Open plugin settings dialog + }} + onBack={() => { + setPluginUploadModalView("upload") + setPluginUploadFilename(null) + }} + />} </div> </div> </GridCard> @@ -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({ ) : ( <Button size="MD" - theme={uploadState === "success" ? "primary" : "light"} + theme="light" text="Back" onClick={onBack} /> @@ -479,4 +538,134 @@ function UploadFileView({ </div> </div> ); +} + +function InstallPluginView({ + filename, + manifest, + onInstall, + onBack, +}: { + filename: string; + manifest: PluginManifest | null; + onInstall: () => void; + onBack: () => void; +}) { + const [send] = useJsonRpc(); + const [error, setError] = useState<string | null>(null); + const [installing, setInstalling] = useState(false); + + function handleInstall() { + if (installing) return; + setInstalling(true); + send("pluginInstall", { name: manifest!.name, version: manifest!.version }, resp => { + if ("error" in resp) { + setError(resp.error.message); + return + } + + setInstalling(false); + onInstall(); + }); + } + + return ( + <div className="w-full space-y-4"> + <ViewHeader + title="Install Plugin" + description={ + !manifest ? + `Extracting plugin from ${filename}...` : + `Do you want to install the plugin?` + } + /> + {manifest && ( + <div className="space-y-2"> + <div className="text-sm text-slate-700 dark:text-slate-300"> + <h3 className="text-lg font-semibold">{manifest.name}</h3> + <p className="text-xs">{manifest.description}</p> + <p className="text-xs"> + Version: {manifest.version} + </p> + <p className="text-xs"> + <a + href={manifest.homepage} + target="_blank" + rel="noreferrer" + className="text-blue-500 dark:text-blue-400" + > + {manifest.homepage} + </a> + </p> + </div> + </div> + )} + {error && ( + <div + className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn" + style={{ animationDuration: "0.7s" }} + > + Error: {error} + </div> + )} + <div + className="space-y-2 opacity-0 animate-fadeIn" + style={{ + animationDuration: "0.7s", + }} + > + <div className="flex justify-end w-full space-x-2"> + <Button + size="MD" + theme="light" + text="Cancel" + onClick={() => { + // TODO: Delete the orphaned extraction + setError(null); + onBack(); + }} + /> + <Button + size="MD" + theme="primary" + text="Install" + onClick={handleInstall} + /> + </div> + </div> + </div> + ); +} + +function ErrorView({ + errorMessage, + onClose, + onRetry, +}: { + errorMessage: string | null; + onClose: () => void; + onRetry: () => void; +}) { + return ( + <div className="w-full space-y-4"> + <div className="space-y-2"> + <div className="flex items-center space-x-2 text-red-600"> + <ExclamationTriangleIcon className="w-6 h-6" /> + <h2 className="text-lg font-bold leading-tight">Plugin Extract Error</h2> + </div> + <p className="text-sm leading-snug text-slate-600"> + An error occurred while attempting to extract the plugin. Please ensure the plugin is valid and try again. + </p> + </div> + {errorMessage && ( + <Card className="p-4 border border-red-200 bg-red-50"> + <p className="text-sm font-medium text-red-800">{errorMessage}</p> + </Card> + )} + <div className="flex justify-end space-x-2"> + <Button size="SM" theme="light" text="Close" onClick={onClose} /> + <Button size="SM" theme="primary" text="Back to Upload" onClick={onRetry} /> + </div> + </div> + ); } \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index bb9439a..769b943 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -253,7 +253,7 @@ export default function SettingsSidebar() { } }; - const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore(); + const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore(); useEffect(() => { getCloudState(); @@ -776,7 +776,10 @@ export default function SettingsSidebar() { size="SM" theme="primary" text="Upload Plugin" - onClick={() => setIsPluginUploadModalOpen(true)} + onClick={() => { + setPluginUploadModalView("upload"); + setIsPluginUploadModalOpen(true) + }} /> <UploadPluginModal open={isPluginUploadModalOpen} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index f29d575..9acaaf7 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -530,12 +530,37 @@ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({ })); +export interface PluginManifest { + name: string; + version: string; + description?: string; + homepage: string; +} + interface PluginState { isPluginUploadModalOpen: boolean; setIsPluginUploadModalOpen: (isOpen: boolean) => void; + + pluginUploadFilename: string | null; + setPluginUploadFilename: (filename: string | null) => void; + + pluginUploadManifest: PluginManifest | null; + setPluginUploadManifest: (manifest: PluginManifest | null) => void; + + pluginUploadModalView: "upload" | "install"; + setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void; } export const usePluginStore = create<PluginState>(set => ({ isPluginUploadModalOpen: false, setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }), -})); \ No newline at end of file + + pluginUploadFilename: null, + setPluginUploadFilename: filename => set({ pluginUploadFilename: filename }), + + pluginUploadManifest: null, + setPluginUploadManifest: manifest => set({ pluginUploadManifest: manifest }), + + pluginUploadModalView: "upload", + setPluginUploadModalView: view => set({ pluginUploadModalView: view }), +}));