Added wip plugin store

- Added plugin store snippet
- Added plugin list sync functions
- Work in progress install / uninstall plugin function
This commit is contained in:
Toby Chui 2025-04-22 07:15:30 +08:00
parent 36c2c9a00e
commit 6750c7fe3d
10 changed files with 469 additions and 3 deletions

View File

@ -234,6 +234,9 @@ func RegisterPluginAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/plugins/groups/add", pluginManager.HandleAddPluginToGroup)
authRouter.HandleFunc("/api/plugins/groups/remove", pluginManager.HandleRemovePluginFromGroup)
authRouter.HandleFunc("/api/plugins/groups/deleteTag", pluginManager.HandleRemovePluginGroup)
authRouter.HandleFunc("/api/plugins/store/list", pluginManager.HandleListDownloadablePlugins)
authRouter.HandleFunc("/api/plugins/store/resync", pluginManager.HandleResyncPluginList)
}
// Register the APIs for Auth functions, due to scoping issue some functions are defined here

View File

@ -249,3 +249,5 @@ func (m *Manager) HandleDisablePlugin(w http.ResponseWriter, r *http.Request) {
utils.SendOK(w)
}
/* Plugin Store */

View File

@ -82,7 +82,7 @@ func (m *Manager) LoadPluginsFromDisk() error {
m.Log("Loaded plugin: "+thisPlugin.Spec.Name, nil)
// If the plugin was enabled, start it now
fmt.Println("Plugin enabled state", m.GetPluginPreviousEnableState(thisPlugin.Spec.ID))
//fmt.Println("Plugin enabled state", m.GetPluginPreviousEnableState(thisPlugin.Spec.ID))
if m.GetPluginPreviousEnableState(thisPlugin.Spec.ID) {
err = m.StartPlugin(thisPlugin.Spec.ID)
if err != nil {

118
src/mod/plugins/store.go Normal file
View File

@ -0,0 +1,118 @@
package plugins
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
"imuslab.com/zoraxy/mod/utils"
)
/*
Plugin Store
*/
// See https://github.com/aroz-online/zoraxy-official-plugins/blob/main/directories/index.json for the standard format
type Checksums struct {
LinuxAmd64 string `json:"linux_amd64"`
Linux386 string `json:"linux_386"`
LinuxArm string `json:"linux_arm"`
LinuxArm64 string `json:"linux_arm64"`
LinuxMipsle string `json:"linux_mipsle"`
LinuxRiscv64 string `json:"linux_riscv64"`
WindowsAmd64 string `json:"windows_amd64"`
}
type DownloadablePlugin struct {
IconPath string
PluginIntroSpect zoraxy_plugin.IntroSpect //Plugin introspect information
ChecksumsSHA256 Checksums //Checksums for the plugin binary
DownloadURLs map[string]string //Download URLs for different platforms
}
/* 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)
if err != nil {
return fmt.Errorf("failed to get plugin list from %s: %w", url, err)
}
m.Options.DownloadablePluginCache = append(m.Options.DownloadablePluginCache, pluginList...)
}
m.Options.LastSuccPluginSyncTime = time.Now().Unix()
return nil
}
// Get the plugin list from the URL
func (m *Manager) getPluginListFromURL(url string) ([]*DownloadablePlugin, error) {
//Get the plugin list from the URL
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get plugin list from %s: %s", url, resp.Status)
}
var pluginList []*DownloadablePlugin
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read plugin list from %s: %w", url, err)
}
content = []byte(strings.TrimSpace(string(content)))
err = json.Unmarshal(content, &pluginList)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal plugin list from %s: %w", url, err)
}
return pluginList, nil
}
func (m *Manager) ListDownloadablePlugins() []*DownloadablePlugin {
//List all downloadable plugins
if len(m.Options.DownloadablePluginCache) == 0 {
return []*DownloadablePlugin{}
}
return m.Options.DownloadablePluginCache
}
/*
Handlers for Plugin Store
*/
func (m *Manager) HandleListDownloadablePlugins(w http.ResponseWriter, r *http.Request) {
//List all downloadable plugins
plugins := m.ListDownloadablePlugins()
js, _ := json.Marshal(plugins)
utils.SendJSONResponse(w, string(js))
}
// 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 {
//Make sure this function require csrf token
utils.SendErrorResponse(w, "Method not allowed")
return
}
//Resync the plugin list from the plugin store URLs
err := m.UpdateDownloadablePluginList()
if err != nil {
utils.SendErrorResponse(w, "Failed to resync plugin list: "+err.Error())
return
}
utils.SendOK(w)
}

View File

@ -0,0 +1,52 @@
package plugins
import (
"testing"
)
func TestUpdateDownloadablePluginList(t *testing.T) {
mockManager := &Manager{
Options: &ManagerOptions{
DownloadablePluginCache: []*DownloadablePlugin{},
PluginStoreURLs: []string{},
},
}
//Inject a mock URL for testing
mockManager.Options.PluginStoreURLs = []string{"https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index.json"}
err := mockManager.UpdateDownloadablePluginList()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(mockManager.Options.DownloadablePluginCache) == 0 {
t.Fatalf("expected plugin cache to be updated, but it was empty")
}
if mockManager.Options.LastSuccPluginSyncTime == 0 {
t.Fatalf("expected LastSuccPluginSyncTime to be updated, but it was not")
}
}
func TestGetPluginListFromURL(t *testing.T) {
mockManager := &Manager{
Options: &ManagerOptions{
DownloadablePluginCache: []*DownloadablePlugin{},
PluginStoreURLs: []string{},
},
}
pluginList, err := mockManager.getPluginListFromURL("https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index.json")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(pluginList) == 0 {
t.Fatalf("expected plugin list to be populated, but it was empty")
}
for _, plugin := range pluginList {
t.Logf("Plugin: %+v", plugin)
}
}

View File

@ -29,10 +29,16 @@ type Plugin struct {
}
type ManagerOptions struct {
/* Plugins */
PluginDir string //The directory where the plugins are stored
PluginGroups map[string][]string //The plugin groups,key is the tag name and the value is an array of plugin IDs
PluginGroupsConfig string //The group / tag configuration file, if set the plugin groups will be loaded from this file
/* Plugin Downloader */
PluginStoreURLs []string //The plugin store URLs, used to download the plugins
DownloadablePluginCache []*DownloadablePlugin //The cache for the downloadable plugins, key is the plugin ID and value is the DownloadablePlugin struct
LastSuccPluginSyncTime int64 //The last sync time for the plugin store URLs, used to check if the plugin store URLs need to be synced again
/* Runtime */
SystemConst *zoraxyPlugin.RuntimeConstantValue //The system constant value
CSRFTokenGen func(*http.Request) string `json:"-"` //The CSRF token generator function

View File

@ -1,7 +1,6 @@
package main
import (
"imuslab.com/zoraxy/mod/auth/sso/authentik"
"log"
"net/http"
"os"
@ -10,6 +9,8 @@ import (
"strings"
"time"
"imuslab.com/zoraxy/mod/auth/sso/authentik"
"github.com/gorilla/csrf"
"imuslab.com/zoraxy/mod/access"
"imuslab.com/zoraxy/mod/acme"
@ -322,6 +323,9 @@ func startupSequence() {
ZoraxyUUID: nodeUUID,
DevelopmentBuild: DEVELOPMENT_BUILD,
},
PluginStoreURLs: []string{
"https://raw.githubusercontent.com/aroz-online/zoraxy-official-plugins/refs/heads/main/directories/index.json",
},
Database: sysdb,
Logger: SystemWideLogger,
PluginGroupsConfig: CONF_PLUGIN_GROUPS,
@ -330,9 +334,19 @@ func startupSequence() {
},
})
//Sync latest plugin list from the plugin store
go func() {
err = pluginManager.UpdateDownloadablePluginList()
if err != nil {
SystemWideLogger.PrintAndLog("plugin-manager", "Failed to sync plugin list from plugin store", err)
} else {
SystemWideLogger.PrintAndLog("plugin-manager", "Plugin list synced from plugin store", nil)
}
}()
err = pluginManager.LoadPluginsFromDisk()
if err != nil {
SystemWideLogger.PrintAndLog("Plugin Manager", "Failed to load plugins", err)
SystemWideLogger.PrintAndLog("plugin-manager", "Failed to load plugins", err)
}
/* Docker UX Optimizer */

View File

@ -185,6 +185,8 @@
</tbody>
</table>
<br>
<button class="ui violet button" onclick="openPluginStore();"><i class="cart arrow down icon"></i>Get More Plugins!</button>
</div>
<script>
@ -563,6 +565,11 @@ function getPluginInfo(pluginId, btn){
showSideWrapper("snippet/pluginInfo.html?t=" + Date.now() + "#" + payload);
}
function openPluginStore(){
//Open plugin store in extended mode
showSideWrapper("snippet/pluginstore.html?t=" + Date.now(), true);
}
</script>

View File

@ -197,6 +197,12 @@ body.darkTheme .menubar{
max-width: calc(80% - 1em);
}
@media screen and (max-width: 478px) {
.sideWrapper.extendedMode {
max-width: calc(100% - 1em);
}
}
.sideWrapper .content{
height: 100%;
width: 100%;

View File

@ -0,0 +1,258 @@
<!DOCTYPE html>
<html>
<head>
<!-- Notes: This should be open in its original path-->
<meta charset="utf-8">
<meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">
<title>Plugin Store</title>
<link rel="stylesheet" href="../script/semantic/semantic.min.css">
<script src="../script/jquery-3.6.0.min.js"></script>
<script src="../script/semantic/semantic.min.js"></script>
<script src="../script/utils.js"></script>
<style>
#pluginList{
padding: 1em;
border: 1px solid #ccc;
height: 500px;
overflow-y: scroll;
}
body.darkTheme #pluginList .header{
color: #fff;
}
.installablePlugin{
position: relative;
}
.installablePlugin .action{
position: absolute;
top: 0.4em;
right: 0.4em;
}
@media screen and (max-width: 768px) {
#pluginList .item .image {
display: none;
}
}
</style>
</head>
<body>
<link rel="stylesheet" href="../darktheme.css">
<script src="../script/darktheme.js"></script>
<br>
<div class="ui container">
<div class="ui fluid search">
<div class="ui fluid icon input">
<input id="searchInput" class="prompt" type="text" placeholder="Search plugins">
<i class="search icon"></i>
</div>
</div>
<div class="ui divided items" id="pluginList">
</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">
<i class="dropdown icon"></i>
Advance Settings
</div>
<div class="content">
<p>Plugin Store URLs</p>
<div class="ui form">
<div class="field">
<textarea id="pluginStoreURLs" rows="5"></textarea>
<label>Enter plugin store URLs, separating each URL with a new line</label>
</div>
<button class="ui basic button" onclick="savePluginStoreURLs()">
<i class="ui green save icon"></i>Save
</button>
</div>
</div>
</div>
</div>
<div class="ui divider"></div>
<div class="field" >
<button class="ui basic button" style="float: right;" onclick="closeThisWrapper();">Close</button>
</div>
<br><br><br><br>
</div>
<script>
let availablePlugins = [];
let installedPlugins = [];
$(".accordion").accordion();
function initStoreList(){
$.get("/api/plugins/list", function(data) {
if (data.error != undefined) {
parent.msgbox(data.error, false);
return;
}else{
installedPlugins = data || [];
console.log(installedPlugins);
}
$.cjax({
url: '/api/plugins/store/list',
type: 'GET',
success: function(data) {
if (data.error != undefined) {
parent.msgbox(data.error, false);
}else{
availablePlugins = data || [];
populatePluginList(availablePlugins);
}
}
});
});
}
initStoreList();
/* Plugin Search */
function searchPlugins() {
const query = document.getElementById('searchInput').value.toLowerCase();
const items = document.querySelectorAll('#pluginList .item');
if (query.trim() === '') {
items.forEach(item => {
item.style.display = '';
});
return;
}
items.forEach(item => {
const name = item.querySelector('.header').textContent.toLowerCase();
const description = item.querySelector('.description p').textContent.toLowerCase();
const author = item.querySelector('.meta span:nth-child(2)').textContent.toLowerCase();
const id = item.querySelector('.extra button').getAttribute('onclick').match(/'(.*?)'/)[1].toLowerCase();
if (name.includes(query) || description.includes(query) || author.includes(query) || id.includes(query)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
}
//Bind search function to input field and Enter key
document.getElementById('searchInput').addEventListener('input', searchPlugins);
document.getElementById('searchInput').addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
searchPlugins();
}
});
function forceResyncPlugins() {
$.cjax({
url: '/api/plugins/store/resync',
type: 'POST',
success: function(data) {
if (data.error != undefined) {
parent.msgbox(data.error, false);
} else {
parent.msgbox("Plugin list updated successfully", true);
initStoreList();
}
}
});
}
/* Plugin Store */
function populatePluginList(plugins) {
const pluginList = document.getElementById('pluginList');
pluginList.innerHTML = ''; // Clear existing items
plugins.forEach(plugin => {
console.log(plugin);
let thisPluginIsInstalled = false;
installedPlugins.forEach(installedPlugin => {
if (installedPlugin.Spec.id == plugin.PluginIntroSpect.id) {
thisPluginIsInstalled = true;
}
});
const item = `
<div class="item installablePlugin" plugin_id="${plugin.PluginIntroSpect.id}">
<div class="ui tiny image">
<img src="${plugin.IconPath}" alt="${plugin.PluginIntroSpect.name}">
</div>
<div class="content">
<div class="header">${plugin.PluginIntroSpect.name}</div>
<div class="meta">
<span>Version: ${plugin.PluginIntroSpect.version_major}.${plugin.PluginIntroSpect.version_minor}.${plugin.PluginIntroSpect.version_patch}</span>
<span>${plugin.PluginIntroSpect.author}</span>
<span><a href="${plugin.PluginIntroSpect.url}">Website</a></span>
</div>
<div class="description">
<p>${plugin.PluginIntroSpect.description}</p>
</div>
<div class="action">
${thisPluginIsInstalled
? `<button class="ui basic circular red button" onclick="uninstallPlugin('${plugin.PluginIntroSpect.id}')"><i class="ui trash icon"></i> Remove</button>`
: `<button class="ui basic circular button" onclick="installPlugin('${plugin.PluginIntroSpect.id}')"><i class="ui download icon"></i> Install</button>`}
</div>
</div>
</div>
`;
$('#pluginList').append(item);
});
}
/* Plugin Actions */
function installPlugin(pluginId) {
$.cjax({
url: '/api/plugins/store/install',
type: 'POST',
data: { pluginId },
success: function(data) {
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();
}
}
});
}
function closeThisWrapper(){
parent.hideSideWrapper(true);
}
/* 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
$.cjax({
url: '/api/plugins/store/saveURLs',
type: 'POST',
data: { urls },
success: function(data) {
if (data.error != undefined) {
parent.msgbox(data.error, false);
} else {
parent.msgbox("URLs saved successfully", true);
}
}
});
}
</script>
</body>
</html>