From ffc67ede129ed6aabfaf0eb241e8ed13c30c756b Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Thu, 24 Apr 2025 21:19:16 +0800 Subject: [PATCH] Added working plugin store prototype - Added plugin install and remove api --- src/api.go | 2 + src/mod/plugins/lifecycle.go | 7 +- src/mod/plugins/plugins.go | 58 ++++++++ src/mod/plugins/store.go | 238 +++++++++++++++++++++++++++++++ src/web/components/plugins.html | 32 ++++- src/web/snippet/pluginstore.html | 57 +++++--- 6 files changed, 362 insertions(+), 32 deletions(-) diff --git a/src/api.go b/src/api.go index 52f6499..1caedeb 100644 --- a/src/api.go +++ b/src/api.go @@ -237,6 +237,8 @@ func RegisterPluginAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/plugins/store/list", pluginManager.HandleListDownloadablePlugins) authRouter.HandleFunc("/api/plugins/store/resync", pluginManager.HandleResyncPluginList) + authRouter.HandleFunc("/api/plugins/store/install", pluginManager.HandleInstallPlugin) + authRouter.HandleFunc("/api/plugins/store/uninstall", pluginManager.HandleUninstallPlugin) } // Register the APIs for Auth functions, due to scoping issue some functions are defined here diff --git a/src/mod/plugins/lifecycle.go b/src/mod/plugins/lifecycle.go index e7b2161..914acf7 100644 --- a/src/mod/plugins/lifecycle.go +++ b/src/mod/plugins/lifecycle.go @@ -274,13 +274,10 @@ func (m *Manager) StopPlugin(pluginID string) error { } // Check if the plugin is still running -func (m *Manager) PluginStillRunning(pluginID string) bool { +func (m *Manager) PluginIsRunning(pluginID string) bool { plugin, err := m.GetPluginByID(pluginID) if err != nil { return false } - if plugin.process == nil { - return false - } - return plugin.process.ProcessState == nil + return plugin.IsRunning() } diff --git a/src/mod/plugins/plugins.go b/src/mod/plugins/plugins.go index 5971ea6..039eaa1 100644 --- a/src/mod/plugins/plugins.go +++ b/src/mod/plugins/plugins.go @@ -58,6 +58,59 @@ func NewPluginManager(options *ManagerOptions) *Manager { } } +// Reload all plugins from disk +func (m *Manager) ReloadPluginFromDisk() { + //Check each of the current plugins if the directory exists + //If not, remove the plugin from the loaded plugins list + m.loadedPluginsMutex.Lock() + for pluginID, plugin := range m.LoadedPlugins { + if !utils.FileExists(plugin.RootDir) { + m.Log("Plugin directory not found, removing plugin from runtime: "+pluginID, nil) + delete(m.LoadedPlugins, pluginID) + //Remove the plugin enable state from the database + m.Options.Database.Delete("plugins", pluginID) + } + } + + m.loadedPluginsMutex.Unlock() + + //Scan the plugin directory for new plugins + foldersInPluginDir, err := os.ReadDir(m.Options.PluginDir) + if err != nil { + m.Log("Failed to read plugin directory", err) + return + } + + for _, folder := range foldersInPluginDir { + if folder.IsDir() { + pluginPath := filepath.Join(m.Options.PluginDir, folder.Name()) + thisPlugin, err := m.LoadPluginSpec(pluginPath) + if err != nil { + m.Log("Failed to load plugin: "+filepath.Base(pluginPath), err) + continue + } + + //Check if the plugin id is already loaded into the runtime + m.loadedPluginsMutex.RLock() + _, ok := m.LoadedPlugins[thisPlugin.Spec.ID] + m.loadedPluginsMutex.RUnlock() + if ok { + //Plugin already loaded, skip it + continue + } + + thisPlugin.RootDir = filepath.ToSlash(pluginPath) + thisPlugin.staticRouteProxy = make(map[string]*dpcore.ReverseProxy) + m.loadedPluginsMutex.Lock() + m.LoadedPlugins[thisPlugin.Spec.ID] = thisPlugin + m.loadedPluginsMutex.Unlock() + m.Log("Added new plugin: "+thisPlugin.Spec.Name, nil) + + // The default state of the plugin is disabled, so no need to start it + } + } +} + // LoadPluginsFromDisk loads all plugins from the plugin directory func (m *Manager) LoadPluginsFromDisk() error { // Load all plugins from the plugin directory @@ -258,3 +311,8 @@ func (p *Plugin) HandleStaticRoute(w http.ResponseWriter, r *http.Request, longe }) } + +// IsRunning checks if the plugin is currently running +func (p *Plugin) IsRunning() bool { + return p.process != nil && p.process.Process != nil +} diff --git a/src/mod/plugins/store.go b/src/mod/plugins/store.go index eff3fcd..fc17833 100644 --- a/src/mod/plugins/store.go +++ b/src/mod/plugins/store.go @@ -1,10 +1,14 @@ package plugins import ( + "crypto/sha256" "encoding/json" "fmt" "io" "net/http" + "os" + "path/filepath" + "runtime" "strings" "time" @@ -89,6 +93,180 @@ func (m *Manager) ListDownloadablePlugins() []*DownloadablePlugin { return m.Options.DownloadablePluginCache } +// InstallPlugin installs the given plugin by moving it to the PluginDir. +func (m *Manager) InstallPlugin(plugin *DownloadablePlugin) error { + pluginDir := filepath.Join(m.Options.PluginDir, plugin.PluginIntroSpect.Name) + pluginFile := plugin.PluginIntroSpect.Name + if runtime.GOOS == "windows" { + pluginFile += ".exe" + } + + //Check if the plugin id already exists in runtime plugin map + if _, ok := m.LoadedPlugins[plugin.PluginIntroSpect.ID]; ok { + return fmt.Errorf("plugin already installed: %s", plugin.PluginIntroSpect.ID) + } + + // Create the plugin directory if it doesn't exist + err := os.MkdirAll(pluginDir, os.ModePerm) + if err != nil { + return fmt.Errorf("failed to create plugin directory: %w", err) + } + + // Download the plugin binary + downloadURL, ok := plugin.DownloadURLs[runtime.GOOS+"_"+runtime.GOARCH] + if !ok { + return fmt.Errorf("no download URL available for the current platform") + } + + resp, err := http.Get(downloadURL) + if err != nil { + return fmt.Errorf("failed to download plugin: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download plugin: %s", resp.Status) + } + + // Write the plugin binary to the plugin directory + pluginPath := filepath.Join(pluginDir, pluginFile) + out, err := os.Create(pluginPath) + if err != nil { + return fmt.Errorf("failed to create plugin file: %w", err) + } + + _, err = io.Copy(out, resp.Body) + if err != nil { + out.Close() + return fmt.Errorf("failed to write plugin file: %w", err) + } + + // Make the plugin executable + err = os.Chmod(pluginPath, 0755) + if err != nil { + out.Close() + return fmt.Errorf("failed to set executable permissions: %w", err) + } + + // Verify the checksum of the downloaded plugin binary + checksums, err := plugin.ChecksumsSHA256.GetCurrentPlatformChecksum() + if err == nil { + if !verifyChecksumForFile(pluginPath, checksums) { + out.Close() + return fmt.Errorf("checksum verification failed for plugin binary") + } + } + + //Ok, also download the icon if exists + if plugin.IconPath != "" { + iconURL := strings.TrimSpace(plugin.IconPath) + if iconURL != "" { + resp, err := http.Get(iconURL) + if err != nil { + return fmt.Errorf("failed to download plugin icon: %w", err) + } + defer resp.Body.Close() + + //Save the icon to the plugin directory + iconPath := filepath.Join(pluginDir, "icon.png") + out, err := os.Create(iconPath) + if err != nil { + return fmt.Errorf("failed to create plugin icon file: %w", err) + } + defer out.Close() + + io.Copy(out, resp.Body) + } + } + //Close the plugin exeutable + out.Close() + + //Reload the plugin list + m.ReloadPluginFromDisk() + + return nil +} + +// UninstallPlugin uninstalls the plugin by removing its directory. +func (m *Manager) UninstallPlugin(pluginID string) error { + + //Stop the plugin process if it's running + plugin, ok := m.LoadedPlugins[pluginID] + if !ok { + return fmt.Errorf("plugin not found: %s", pluginID) + } + + if plugin.IsRunning() { + err := m.StopPlugin(plugin.Spec.ID) + if err != nil { + return fmt.Errorf("failed to stop plugin: %w", err) + } + } + + //Make sure the plugin process is stopped + m.Options.Logger.PrintAndLog("plugin-manager", "Removing plugin in 3 seconds...", nil) + time.Sleep(3 * time.Second) + + // Remove the plugin directory + err := os.RemoveAll(plugin.RootDir) + if err != nil { + return fmt.Errorf("failed to remove plugin directory: %w", err) + } + + //Reload the plugin list + m.ReloadPluginFromDisk() + return nil +} + +// GetCurrentPlatformChecksum returns the checksum for the current platform +func (c *Checksums) GetCurrentPlatformChecksum() (string, error) { + switch runtime.GOOS { + case "linux": + switch runtime.GOARCH { + case "amd64": + return c.LinuxAmd64, nil + case "386": + return c.Linux386, nil + case "arm": + return c.LinuxArm, nil + case "arm64": + return c.LinuxArm64, nil + case "mipsle": + return c.LinuxMipsle, nil + case "riscv64": + return c.LinuxRiscv64, nil + default: + return "", fmt.Errorf("unsupported architecture: %s", runtime.GOARCH) + } + case "windows": + switch runtime.GOARCH { + case "amd64": + return c.WindowsAmd64, nil + default: + return "", fmt.Errorf("unsupported architecture: %s", runtime.GOARCH) + } + default: + return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } +} + +// VerifyChecksum verifies the checksum of the downloaded plugin binary. +func verifyChecksumForFile(filePath string, checksum string) bool { + file, err := os.Open(filePath) + if err != nil { + return false + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return false + } + calculatedChecksum := fmt.Sprintf("%x", hash.Sum(nil)) + + return calculatedChecksum == checksum +} + /* Handlers for Plugin Store */ @@ -116,3 +294,63 @@ func (m *Manager) HandleResyncPluginList(w http.ResponseWriter, r *http.Request) } utils.SendOK(w) } + +// HandleInstallPlugin is the handler for installing a plugin +func (m *Manager) HandleInstallPlugin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + utils.SendErrorResponse(w, "Method not allowed") + return + } + + pluginID, err := utils.PostPara(r, "pluginID") + if err != nil { + utils.SendErrorResponse(w, "pluginID is required") + return + } + + // Find the plugin info from cache + var plugin *DownloadablePlugin + for _, p := range m.Options.DownloadablePluginCache { + if p.PluginIntroSpect.ID == pluginID { + plugin = p + break + } + } + + if plugin == nil { + utils.SendErrorResponse(w, "Plugin not found") + return + } + + // Install the plugin (implementation depends on your system) + err = m.InstallPlugin(plugin) + if err != nil { + utils.SendErrorResponse(w, "Failed to install plugin: "+err.Error()) + return + } + + utils.SendOK(w) +} + +// HandleUninstallPlugin is the handler for uninstalling a plugin +func (m *Manager) HandleUninstallPlugin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + utils.SendErrorResponse(w, "Method not allowed") + return + } + + pluginID, err := utils.PostPara(r, "pluginID") + if err != nil { + utils.SendErrorResponse(w, "pluginID is required") + return + } + + // Uninstall the plugin (implementation depends on your system) + err = m.UninstallPlugin(pluginID) + if err != nil { + utils.SendErrorResponse(w, "Failed to uninstall plugin: "+err.Error()) + return + } + + utils.SendOK(w) +} diff --git a/src/web/components/plugins.html b/src/web/components/plugins.html index bae23ba..9592723 100644 --- a/src/web/components/plugins.html +++ b/src/web/components/plugins.html @@ -186,7 +186,7 @@
- + diff --git a/src/web/snippet/pluginstore.html b/src/web/snippet/pluginstore.html index a7a4b91..3f479d4 100644 --- a/src/web/snippet/pluginstore.html +++ b/src/web/snippet/pluginstore.html @@ -43,6 +43,10 @@
+
+
Experimental Feature
+

The Plugin Store is an experimental feature. Use it at your own risk.

+
`; $('#pluginList').append(item); }); + + // Reapply search filter if there's a query in the search bar + const searchQuery = document.getElementById('searchInput').value.toLowerCase(); + if (searchQuery.trim() !== '') { + searchPlugins(); + } + } /* Plugin Actions */ - function installPlugin(pluginId) { + function installPlugin(pluginId, btn=undefined) { + if (btn !== undefined) { + $(btn).addClass('loading').prop('disabled', true); + } $.cjax({ url: '/api/plugins/store/install', type: 'POST', - data: { pluginId }, + data: { "pluginID": pluginId }, success: function(data) { + if (btn !== undefined) { + $(btn).removeClass('loading').prop('disabled', false); + } if (data.error != undefined) { parent.msgbox(data.error, false); } else { parent.msgbox("Plugin installed successfully", true); initStoreList(); - } - } - }); - } - function uninstallPlugin(pluginId) { - $.cjax({ - url: '/api/plugins/store/uninstall', - type: 'POST', - data: { pluginId }, - success: function(data) { - if (data.error != undefined) { - parent.msgbox(data.error, false); - } else { - parent.msgbox("Plugin uninstalled successfully", true); - initStoreList(); + //Also reload the parent plugin list + parent.initiatePluginList(); } + }, + error: function() { + if (btn !== undefined) { + $(btn).removeClass('loading').prop('disabled', false); + } + parent.msgbox("An error occurred while installing the plugin", false); } }); }