-
Notifications
You must be signed in to change notification settings - Fork 286
feat: better plugin manager #1097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v3.3.3
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,6 +5,7 @@ import ( | |||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||
| "io" | ||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||
| urlpkg "net/url" | ||||||||||||||||||||||
| "os" | ||||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||||
| "runtime" | ||||||||||||||||||||||
|
|
@@ -23,26 +24,103 @@ import ( | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| // See https://github.com/aroz-online/zoraxy-official-plugins/blob/main/directories/index2.json for the standard format | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| var defaultPluginStoreURLs = []string{ | ||||||||||||||||||||||
| "https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index2.json", | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type DownloadablePlugin struct { | ||||||||||||||||||||||
| IconPath string `json:"IconPath"` //Icon path or URL for the plugin | ||||||||||||||||||||||
| PluginIntroSpect zoraxy_plugin.IntroSpect `json:"PluginIntroSpect"` //Plugin introspect information | ||||||||||||||||||||||
| DownloadURLs map[string]string `json:"DownloadURLs"` //Download URLs for different platforms | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func GetDefaultPluginStoreURLs() []string { | ||||||||||||||||||||||
| return append([]string{}, defaultPluginStoreURLs...) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func normalizePluginStoreURLs(rawURLs []string) []string { | ||||||||||||||||||||||
| seen := map[string]bool{} | ||||||||||||||||||||||
| normalized := []string{} | ||||||||||||||||||||||
| for _, rawURL := range rawURLs { | ||||||||||||||||||||||
| trimmed := strings.TrimSpace(rawURL) | ||||||||||||||||||||||
| if trimmed == "" || seen[trimmed] { | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| seen[trimmed] = true | ||||||||||||||||||||||
| normalized = append(normalized, trimmed) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if len(normalized) == 0 { | ||||||||||||||||||||||
| return GetDefaultPluginStoreURLs() | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return normalized | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) GetPluginStoreURLs() []string { | ||||||||||||||||||||||
| return normalizePluginStoreURLs(m.Options.PluginStoreURLs) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) LoadPluginStoreURLs() error { | ||||||||||||||||||||||
| if m.Options.Database == nil { | ||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalizePluginStoreURLs(m.Options.PluginStoreURLs) | ||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| loaded := []string{} | ||||||||||||||||||||||
| if err := m.Options.Database.Read("plugins", "store_urls", &loaded); err != nil { | ||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalizePluginStoreURLs(m.Options.PluginStoreURLs) | ||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalizePluginStoreURLs(loaded) | ||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) SavePluginStoreURLs(urls []string) error { | ||||||||||||||||||||||
| normalized := normalizePluginStoreURLs(urls) | ||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalized | ||||||||||||||||||||||
| if m.Options.Database == nil { | ||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return m.Options.Database.Write("plugins", "store_urls", normalized) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+78
to
+85
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /* Plugin Store Index List Sync */ | ||||||||||||||||||||||
| //Update the plugin list from the plugin store URLs | ||||||||||||||||||||||
| func (m *Manager) UpdateDownloadablePluginList() error { | ||||||||||||||||||||||
| //Get downloadable plugins from each of the plugin store URLS | ||||||||||||||||||||||
| m.Options.DownloadablePluginCache = []*DownloadablePlugin{} | ||||||||||||||||||||||
| for _, url := range m.Options.PluginStoreURLs { | ||||||||||||||||||||||
| pluginList, err := m.getPluginListFromURL(url) | ||||||||||||||||||||||
| m.Options.PluginStoreURLs = normalizePluginStoreURLs(m.Options.PluginStoreURLs) | ||||||||||||||||||||||
| combined := []*DownloadablePlugin{} | ||||||||||||||||||||||
| seenPluginIDs := map[string]bool{} | ||||||||||||||||||||||
| errors := []string{} | ||||||||||||||||||||||
| successfulSources := 0 | ||||||||||||||||||||||
| for _, sourceURL := range m.Options.PluginStoreURLs { | ||||||||||||||||||||||
| pluginList, err := m.getPluginListFromURL(sourceURL) | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| return fmt.Errorf("failed to get plugin list from %s: %w", url, err) | ||||||||||||||||||||||
| errors = append(errors, fmt.Sprintf("%s: %s", sourceURL, err.Error())) | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| successfulSources++ | ||||||||||||||||||||||
| for _, plugin := range pluginList { | ||||||||||||||||||||||
| if plugin == nil || plugin.PluginIntroSpect.ID == "" { | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if seenPluginIDs[plugin.PluginIntroSpect.ID] { | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| seenPluginIDs[plugin.PluginIntroSpect.ID] = true | ||||||||||||||||||||||
| combined = append(combined, plugin) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| m.Options.DownloadablePluginCache = append(m.Options.DownloadablePluginCache, pluginList...) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| m.Options.LastSuccPluginSyncTime = time.Now().Unix() | ||||||||||||||||||||||
| m.Options.DownloadablePluginCache = combined | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if successfulSources > 0 { | ||||||||||||||||||||||
| m.Options.LastSuccPluginSyncTime = time.Now().Unix() | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if successfulSources == 0 && len(errors) > 0 { | ||||||||||||||||||||||
| return fmt.Errorf("failed to sync any plugin repository: %s", strings.Join(errors, "; ")) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
@@ -72,16 +150,42 @@ func (m *Manager) getPluginListFromURL(url string) ([]*DownloadablePlugin, error | |||||||||||||||||||||
| return nil, fmt.Errorf("failed to unmarshal plugin list from %s: %w", url, err) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| baseURL, _ := urlpkg.Parse(url) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Filter out if IconPath is empty string, set it to "/img/plugin_icon.png" | ||||||||||||||||||||||
| for _, plugin := range pluginList { | ||||||||||||||||||||||
|
Comment on lines
150
to
156
|
||||||||||||||||||||||
| if strings.TrimSpace(plugin.IconPath) == "" { | ||||||||||||||||||||||
| plugin.IconPath = "/img/plugin_icon.png" | ||||||||||||||||||||||
| } else if resolvedURL := resolvePluginStoreAssetURL(baseURL, plugin.IconPath); resolvedURL != "" { | ||||||||||||||||||||||
| plugin.IconPath = resolvedURL | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| for platform, downloadURL := range plugin.DownloadURLs { | ||||||||||||||||||||||
| if resolvedURL := resolvePluginStoreAssetURL(baseURL, downloadURL); resolvedURL != "" { | ||||||||||||||||||||||
| plugin.DownloadURLs[platform] = resolvedURL | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return pluginList, nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func resolvePluginStoreAssetURL(baseURL *urlpkg.URL, raw string) string { | ||||||||||||||||||||||
| raw = strings.TrimSpace(raw) | ||||||||||||||||||||||
| if raw == "" { | ||||||||||||||||||||||
| return "" | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| parsed, err := urlpkg.Parse(raw) | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| return raw | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if parsed.IsAbs() || baseURL == nil { | ||||||||||||||||||||||
| return raw | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return baseURL.ResolveReference(parsed).String() | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) ListDownloadablePlugins() []*DownloadablePlugin { | ||||||||||||||||||||||
| //List all downloadable plugins | ||||||||||||||||||||||
| if len(m.Options.DownloadablePluginCache) == 0 { | ||||||||||||||||||||||
|
|
@@ -217,6 +321,43 @@ func (m *Manager) HandleListDownloadablePlugins(w http.ResponseWriter, r *http.R | |||||||||||||||||||||
| utils.SendJSONResponse(w, string(js)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (m *Manager) HandlePluginStoreURLs(w http.ResponseWriter, r *http.Request) { | ||||||||||||||||||||||
| switch r.Method { | ||||||||||||||||||||||
| case http.MethodGet: | ||||||||||||||||||||||
| payload := map[string]any{ | ||||||||||||||||||||||
| "urls": m.GetPluginStoreURLs(), | ||||||||||||||||||||||
| "default_urls": GetDefaultPluginStoreURLs(), | ||||||||||||||||||||||
| "last_sync_unix": m.Options.LastSuccPluginSyncTime, | ||||||||||||||||||||||
| "repository_count": len(m.GetPluginStoreURLs()), | ||||||||||||||||||||||
| "downloadable_count": len(m.Options.DownloadablePluginCache), | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| js, _ := json.Marshal(payload) | ||||||||||||||||||||||
| utils.SendJSONResponse(w, string(js)) | ||||||||||||||||||||||
| case http.MethodPost: | ||||||||||||||||||||||
| rawURLs, err := utils.PostPara(r, "urls") | ||||||||||||||||||||||
| if err != nil && !strings.Contains(err.Error(), "invalid urls") { | ||||||||||||||||||||||
| utils.SendErrorResponse(w, "urls not found") | ||||||||||||||||||||||
| return | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+337
to
+341
|
||||||||||||||||||||||
| rawURLs, err := utils.PostPara(r, "urls") | |
| if err != nil && !strings.Contains(err.Error(), "invalid urls") { | |
| utils.SendErrorResponse(w, "urls not found") | |
| return | |
| } | |
| if err := r.ParseForm(); err != nil { | |
| utils.SendErrorResponse(w, "invalid form data: "+err.Error()) | |
| return | |
| } | |
| rawURLs := r.FormValue("urls") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,25 @@ func (m *Manager) GetPluginEntryPoint(folderpath string) (string, error) { | |
| if !info.IsDir() { | ||
| return "", errors.New("path is not a directory") | ||
| } | ||
|
|
||
| startScriptPath := filepath.Join(folderpath, "start.sh") | ||
| startBatchPath := filepath.Join(folderpath, "start.bat") | ||
| goModPath := filepath.Join(folderpath, "go.mod") | ||
|
|
||
| // Prefer source entrypoints for plugin repositories that ship source code. | ||
| // This avoids stale checked-in binaries shadowing newer API/UI changes. | ||
| if _, err := os.Stat(goModPath); err == nil { | ||
| if runtime.GOOS == "windows" { | ||
| if _, err := os.Stat(startBatchPath); err == nil { | ||
| return startBatchPath, nil | ||
| } | ||
| } else { | ||
| if _, err := os.Stat(startScriptPath); err == nil { | ||
| return startScriptPath, nil | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
Comment on lines
+40
to
+53
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure about this. Typically plugins should ship the executable, and if you're developing a plugin you can just recompile it to get the latest version.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right — this is specific to Go, so checking for go.mod here isn't necessary. We'll still get support for Windows and other operating systems. |
||
| expectedBinaryPath := filepath.Join(folderpath, filepath.Base(folderpath)) | ||
| if runtime.GOOS == "windows" { | ||
| expectedBinaryPath += ".exe" | ||
|
|
@@ -41,12 +60,12 @@ func (m *Manager) GetPluginEntryPoint(folderpath string) (string, error) { | |
| return expectedBinaryPath, nil | ||
| } | ||
|
|
||
| if _, err := os.Stat(filepath.Join(folderpath, "start.sh")); err == nil { | ||
| return filepath.Join(folderpath, "start.sh"), nil | ||
| if _, err := os.Stat(startScriptPath); err == nil { | ||
| return startScriptPath, nil | ||
| } | ||
|
|
||
| if _, err := os.Stat(filepath.Join(folderpath, "start.bat")); err == nil { | ||
| return filepath.Join(folderpath, "start.bat"), nil | ||
| if _, err := os.Stat(startBatchPath); err == nil { | ||
| return startBatchPath, nil | ||
| } | ||
|
|
||
| return "", errors.New("no valid entry point found") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thisPlugin.Spec.IDis used as a path element when creatingpluginConfiguration.DataDir. Since plugin IDs ultimately come from plugin introspection, they should be treated as untrusted: values containing path separators (e.g.../) could escapePluginDataDirand create/write directories elsewhere. Consider sanitizing/validating the plugin ID for filesystem use (e.g., allowlist[A-Za-z0-9._-]only, or usefilepath.Base/reject ifCleanchanges it) before callingfilepath.Join/MkdirAll.