mirror of
https://github.com/tobychui/zoraxy.git
synced 2025-05-31 04:37:20 +02:00
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:
parent
36c2c9a00e
commit
6750c7fe3d
@ -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
|
||||
|
@ -249,3 +249,5 @@ func (m *Manager) HandleDisablePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
utils.SendOK(w)
|
||||
}
|
||||
|
||||
/* Plugin Store */
|
||||
|
@ -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
118
src/mod/plugins/store.go
Normal 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)
|
||||
}
|
52
src/mod/plugins/store_test.go
Normal file
52
src/mod/plugins/store_test.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
18
src/start.go
18
src/start.go
@ -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 */
|
||||
|
@ -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>
|
||||
|
||||
|
||||
|
@ -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%;
|
||||
|
258
src/web/snippet/pluginstore.html
Normal file
258
src/web/snippet/pluginstore.html
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user