Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ func RegisterPluginAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/plugins/groups/deleteTag", pluginManager.HandleRemovePluginGroup)

authRouter.HandleFunc("/api/plugins/store/list", pluginManager.HandleListDownloadablePlugins)
authRouter.HandleFunc("/api/plugins/store/urls", pluginManager.HandlePluginStoreURLs)
authRouter.HandleFunc("/api/plugins/store/resync", pluginManager.HandleResyncPluginList)
authRouter.HandleFunc("/api/plugins/store/install", pluginManager.HandleInstallPlugin)
authRouter.HandleFunc("/api/plugins/store/uninstall", pluginManager.HandleUninstallPlugin)
Expand Down
8 changes: 7 additions & 1 deletion src/mod/plugins/introspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ func (m *Manager) GetPluginSpec(entryPoint string) (*zoraxyPlugin.IntroSpect, er
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, entryPoint, "-introspect")
absoluteEntryPoint, err := filepath.Abs(entryPoint)
if err != nil {
return nil, err
}

cmd := exec.CommandContext(ctx, absoluteEntryPoint, "-introspect")
cmd.Dir = filepath.Dir(absoluteEntryPoint)
output, err := cmd.Output()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("plugin introspect timed out")
Expand Down
153 changes: 147 additions & 6 deletions src/mod/plugins/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
urlpkg "net/url"
"os"
"path/filepath"
"runtime"
Expand All @@ -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
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SavePluginStoreURLs always normalizes an empty list to the current defaults and then persists that default slice to the database. That means “reset to defaults” will pin the user to today’s default list and they won’t automatically pick up future default repository additions/removals. Consider treating an empty input as “unset override” (e.g., Database.Delete("plugins","store_urls")) while keeping m.Options.PluginStoreURLs in-memory set to GetDefaultPluginStoreURLs().

Copilot uses AI. Check for mistakes.
Comment on lines 24 to +85
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New persistence/normalization logic for repository URLs and the new /api/plugins/store/urls handler aren’t covered by existing store tests (which currently cover list sync and fetching). Adding unit tests for URL normalization/dedup/default fallback, Save/Load round-trips (including empty-reset semantics), and handler GET/POST behavior would help prevent regressions.

Copilot uses AI. Check for mistakes.

/* 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
}
Expand Down Expand Up @@ -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
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In getPluginListFromURL, repository fetches are done via http.Get (default client) without any timeout. Now that sync iterates over multiple sources, a single slow/hung repository can block resync/startup sync for an unbounded time. Consider switching to an http.Client{Timeout: ...} and/or http.NewRequestWithContext with a deadline so each source has a bounded cost.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good point, maybe it'd be better to pull each list in parallel with a timeout (say, 20 seconds or something) and collect results over a channel?

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 {
Expand Down Expand Up @@ -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
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HandlePluginStoreURLs relies on substring-matching the error message from utils.PostPara (strings.Contains(err.Error(), "invalid urls")) to decide whether to treat a missing/empty value as a reset. This is brittle (it couples behavior to an error string format) and can misbehave if PostPara changes. Consider handling this explicitly (e.g., accept missing/empty urls as reset without checking the error string, but still fail on ParseForm errors).

Suggested change
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")

Copilot uses AI. Check for mistakes.
urls := []string{}
if strings.TrimSpace(rawURLs) != "" {
for _, line := range strings.Split(rawURLs, "\n") {
line = strings.TrimSpace(line)
if line != "" {
urls = append(urls, line)
}
}
}
if err := m.SavePluginStoreURLs(urls); err != nil {
utils.SendErrorResponse(w, "failed to save plugin store URLs: "+err.Error())
return
}
utils.SendOK(w)
default:
utils.SendErrorResponse(w, "Method not allowed")
}
}

// HandleResyncPluginList is the handler for resyncing the plugin list from the plugin store URLs
func (m *Manager) HandleResyncPluginList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Expand Down
27 changes: 23 additions & 4 deletions src/mod/plugins/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.
Also, not every plugin is implemented in Go (ex: rust api) so this isn't a reliable detection mechanism

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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"
Expand All @@ -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")
Expand Down
8 changes: 4 additions & 4 deletions src/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,14 +388,14 @@ func startupSequence() {
DevelopmentBuild: *development_build,
},
/* Plugin Store URLs */
PluginStoreURLs: []string{
"https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index2.json",
//TO BE ADDED
},
PluginStoreURLs: plugins.GetDefaultPluginStoreURLs(),
/* Developer Options */
EnableHotReload: *development_build, //Default to true if development build
HotReloadInterval: 5, //seconds
})
if err := pluginManager.LoadPluginStoreURLs(); err != nil {
SystemWideLogger.PrintAndLog("plugin-manager", "Failed to load plugin store URLs; using defaults", err)
}

/*
Event Manager
Expand Down
58 changes: 36 additions & 22 deletions src/web/snippet/pluginstore.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,29 +90,27 @@

</div>
<button class="ui basic button" onclick="forceResyncPlugins();"><i class="ui green refresh icon"></i> Update Plugin List</button>
<!-- <div class="ui divider"></div>
<div class="ui basic segment advanceoptions">
<div class="ui accordion advanceSettings">
<div class="title">
<div class="ui divider"></div>
<div class="ui basic segment advanceoptions">
<div class="ui accordion advanceSettings">
<div class="title">
<i class="dropdown icon"></i>
Advance Settings
</div>
<div class="content">
<p>Plugin Store URLs</p>
<div class="ui form">
Advanced Settings
</div>
<div class="content">
<div class="ui form">
<div class="field">
<label>Plugin Store Repositories</label>
<textarea id="pluginStoreURLs" rows="5"></textarea>
<label>Enter plugin store URLs, separating each URL with a new line</label>
<small>One repository index URL per line. Leave empty to restore the default repository list.</small>
</div>
<button class="ui basic button" onclick="savePluginStoreURLs()">
<i class="ui green save icon"></i>Save
<button class="ui basic button" onclick="savePluginManagerURLs()">
<i class="ui green save icon"></i> Save
</button>
</div>

</div>
</div>
</div>
-->
</div>
<div class="ui divider"></div>
<div class="field" >
<button class="ui basic button" style="float: right;" onclick="closeThisWrapper();">Close</button>
Expand Down Expand Up @@ -168,6 +166,18 @@
}
initStoreList();

function initPluginStoreRepositories() {
$.get('/api/plugins/store/urls', function(data) {
if (data.error != undefined) {
parent.msgbox(data.error, false);
return;
}
const urls = data.urls || [];
$('#pluginStoreURLs').val(urls.join('\n'));
});
}
initPluginStoreRepositories();

/* Plugin Search */
function searchPlugins() {
const query = document.getElementById('searchInput').value.toLowerCase();
Expand Down Expand Up @@ -213,6 +223,7 @@
} else {
parent.msgbox("Plugin list updated successfully", true);
initStoreList();
initPluginStoreRepositories();
}
}
});
Expand Down Expand Up @@ -383,22 +394,25 @@

/* Advanced Options */
function savePluginManagerURLs() {
const urls = document.getElementById('pluginStoreURLs').value.split('\n').map(url => url.trim()).filter(url => url !== '');
console.log('Saving URLs:', urls);
// Add your logic to save the URLs here, e.g., send them to the server
const urls = document.getElementById('pluginStoreURLs').value
.split('\n')
.map(url => url.trim())
.filter(url => url !== '');
$.cjax({
url: '/api/plugins/store/saveURLs',
url: '/api/plugins/store/urls',
type: 'POST',
data: { urls },
data: { urls: urls.join('\n') },
success: function(data) {
if (data.error != undefined) {
parent.msgbox(data.error, false);
} else {
parent.msgbox("URLs saved successfully", true);
parent.msgbox("Plugin store repositories saved", true);
initPluginStoreRepositories();
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After a successful save, this calls both initPluginStoreRepositories() and then forceResyncPlugins(), but forceResyncPlugins() already calls initPluginStoreRepositories() on success. This causes an extra GET request and can make the UI do redundant work; consider removing the standalone initPluginStoreRepositories() here and relying on the resync callback (or vice versa).

Suggested change
initPluginStoreRepositories();

Copilot uses AI. Check for mistakes.
forceResyncPlugins();
}
}
});
}
</script>
</body>
</html>
</html>