mirror of
				https://github.com/tobychui/zoraxy.git
				synced 2025-11-04 07:54:12 +01:00 
			
		
		
		
	Added UI for plugin system and upnp example
- Added wip UI for plugin tag system - Added upnp port forwarder plugin - Added error and fatal printout for plugins - Optimized UI flow for plugin context window - Added dev web server for plugin development purpose
This commit is contained in:
		
							
								
								
									
										145
									
								
								example/plugins/debugger/mod/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								example/plugins/debugger/mod/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,145 @@
 | 
			
		||||
package zoraxy_plugin
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type PluginUiDebugRouter struct {
 | 
			
		||||
	PluginID         string //The ID of the plugin
 | 
			
		||||
	TargetDir        string //The directory where the UI files are stored
 | 
			
		||||
	HandlerPrefix    string //The prefix of the handler used to route this router, e.g. /ui
 | 
			
		||||
	EnableDebug      bool   //Enable debug mode
 | 
			
		||||
	terminateHandler func() //The handler to be called when the plugin is terminated
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
 | 
			
		||||
// The targetDir is the directory where the UI files are stored (e.g. ./www)
 | 
			
		||||
// The handlerPrefix is the prefix of the handler used to route this router
 | 
			
		||||
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
 | 
			
		||||
// All prefix should not end with a slash
 | 
			
		||||
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
 | 
			
		||||
	//Make sure all prefix are in /prefix format
 | 
			
		||||
	if !strings.HasPrefix(handlerPrefix, "/") {
 | 
			
		||||
		handlerPrefix = "/" + handlerPrefix
 | 
			
		||||
	}
 | 
			
		||||
	handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
 | 
			
		||||
 | 
			
		||||
	//Return the PluginUiRouter
 | 
			
		||||
	return &PluginUiDebugRouter{
 | 
			
		||||
		PluginID:      pluginID,
 | 
			
		||||
		TargetDir:     targetDir,
 | 
			
		||||
		HandlerPrefix: handlerPrefix,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
 | 
			
		||||
	//Get the CSRF token from header
 | 
			
		||||
	csrfToken := r.Header.Get("X-Zoraxy-Csrf")
 | 
			
		||||
	if csrfToken == "" {
 | 
			
		||||
		csrfToken = "missing-csrf-token"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Return the middleware
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		// Check if the request is for an HTML file
 | 
			
		||||
		if strings.HasSuffix(r.URL.Path, ".html") {
 | 
			
		||||
			//Read the target file from file system
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			body := string(targetFileContent)
 | 
			
		||||
			body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
			w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			//Check if the request is for a directory
 | 
			
		||||
			//Check if the directory has an index.html file
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			if _, err := os.Stat(targetFilePath); err == nil {
 | 
			
		||||
				//Serve the index.html file
 | 
			
		||||
				targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
				body := string(targetFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
		fsHandler.ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetHttpHandler returns the http.Handler for the PluginUiRouter
 | 
			
		||||
func (p *PluginUiDebugRouter) Handler() http.Handler {
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		//Remove the plugin UI handler path prefix
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rewrittenURL := r.RequestURI
 | 
			
		||||
		rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
 | 
			
		||||
		rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
 | 
			
		||||
		r.URL.Path = rewrittenURL
 | 
			
		||||
		r.RequestURI = rewrittenURL
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Println(r.URL.Path)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Serve the file from the file system
 | 
			
		||||
		fsHandler := http.FileServer(http.Dir(p.TargetDir))
 | 
			
		||||
 | 
			
		||||
		// Replace {{csrf_token}} with the actual CSRF token and serve the file
 | 
			
		||||
		p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
 | 
			
		||||
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
 | 
			
		||||
// if mux is nil, the handler will be registered to http.DefaultServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
 | 
			
		||||
	p.terminateHandler = termFunc
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
	mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		p.terminateHandler()
 | 
			
		||||
		w.WriteHeader(http.StatusOK)
 | 
			
		||||
		go func() {
 | 
			
		||||
			//Make sure the response is sent before the plugin is terminated
 | 
			
		||||
			time.Sleep(100 * time.Millisecond)
 | 
			
		||||
			os.Exit(0)
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the file system UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
@@ -74,6 +74,20 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			// Check if the directory has an index.html file
 | 
			
		||||
			indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html"
 | 
			
		||||
			indexFilePath = p.TargetFsPrefix + "/" + indexFilePath
 | 
			
		||||
			indexFilePath = strings.TrimPrefix(indexFilePath, "/")
 | 
			
		||||
			indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				body := string(indexFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
@@ -130,3 +144,13 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the embed UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										145
									
								
								example/plugins/helloworld/mod/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								example/plugins/helloworld/mod/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,145 @@
 | 
			
		||||
package zoraxy_plugin
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type PluginUiDebugRouter struct {
 | 
			
		||||
	PluginID         string //The ID of the plugin
 | 
			
		||||
	TargetDir        string //The directory where the UI files are stored
 | 
			
		||||
	HandlerPrefix    string //The prefix of the handler used to route this router, e.g. /ui
 | 
			
		||||
	EnableDebug      bool   //Enable debug mode
 | 
			
		||||
	terminateHandler func() //The handler to be called when the plugin is terminated
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
 | 
			
		||||
// The targetDir is the directory where the UI files are stored (e.g. ./www)
 | 
			
		||||
// The handlerPrefix is the prefix of the handler used to route this router
 | 
			
		||||
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
 | 
			
		||||
// All prefix should not end with a slash
 | 
			
		||||
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
 | 
			
		||||
	//Make sure all prefix are in /prefix format
 | 
			
		||||
	if !strings.HasPrefix(handlerPrefix, "/") {
 | 
			
		||||
		handlerPrefix = "/" + handlerPrefix
 | 
			
		||||
	}
 | 
			
		||||
	handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
 | 
			
		||||
 | 
			
		||||
	//Return the PluginUiRouter
 | 
			
		||||
	return &PluginUiDebugRouter{
 | 
			
		||||
		PluginID:      pluginID,
 | 
			
		||||
		TargetDir:     targetDir,
 | 
			
		||||
		HandlerPrefix: handlerPrefix,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
 | 
			
		||||
	//Get the CSRF token from header
 | 
			
		||||
	csrfToken := r.Header.Get("X-Zoraxy-Csrf")
 | 
			
		||||
	if csrfToken == "" {
 | 
			
		||||
		csrfToken = "missing-csrf-token"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Return the middleware
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		// Check if the request is for an HTML file
 | 
			
		||||
		if strings.HasSuffix(r.URL.Path, ".html") {
 | 
			
		||||
			//Read the target file from file system
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			body := string(targetFileContent)
 | 
			
		||||
			body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
			w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			//Check if the request is for a directory
 | 
			
		||||
			//Check if the directory has an index.html file
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			if _, err := os.Stat(targetFilePath); err == nil {
 | 
			
		||||
				//Serve the index.html file
 | 
			
		||||
				targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
				body := string(targetFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
		fsHandler.ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetHttpHandler returns the http.Handler for the PluginUiRouter
 | 
			
		||||
func (p *PluginUiDebugRouter) Handler() http.Handler {
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		//Remove the plugin UI handler path prefix
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rewrittenURL := r.RequestURI
 | 
			
		||||
		rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
 | 
			
		||||
		rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
 | 
			
		||||
		r.URL.Path = rewrittenURL
 | 
			
		||||
		r.RequestURI = rewrittenURL
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Println(r.URL.Path)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Serve the file from the file system
 | 
			
		||||
		fsHandler := http.FileServer(http.Dir(p.TargetDir))
 | 
			
		||||
 | 
			
		||||
		// Replace {{csrf_token}} with the actual CSRF token and serve the file
 | 
			
		||||
		p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
 | 
			
		||||
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
 | 
			
		||||
// if mux is nil, the handler will be registered to http.DefaultServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
 | 
			
		||||
	p.terminateHandler = termFunc
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
	mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		p.terminateHandler()
 | 
			
		||||
		w.WriteHeader(http.StatusOK)
 | 
			
		||||
		go func() {
 | 
			
		||||
			//Make sure the response is sent before the plugin is terminated
 | 
			
		||||
			time.Sleep(100 * time.Millisecond)
 | 
			
		||||
			os.Exit(0)
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the file system UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
@@ -74,6 +74,20 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			// Check if the directory has an index.html file
 | 
			
		||||
			indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html"
 | 
			
		||||
			indexFilePath = p.TargetFsPrefix + "/" + indexFilePath
 | 
			
		||||
			indexFilePath = strings.TrimPrefix(indexFilePath, "/")
 | 
			
		||||
			indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				body := string(indexFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
@@ -130,3 +144,13 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the embed UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										327
									
								
								example/plugins/upnp/api.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										327
									
								
								example/plugins/upnp/api.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,327 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
	API Handlers
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
func handleUsableState(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if r.Method == "GET" {
 | 
			
		||||
		js, _ := json.Marshal(upnpRouterExists)
 | 
			
		||||
		SendJSONResponse(w, string(js))
 | 
			
		||||
	} else if r.Method == "POST" {
 | 
			
		||||
		//Try to probe the UPnP router again
 | 
			
		||||
		TryStartUPnPClient()
 | 
			
		||||
		if upnpRouterExists {
 | 
			
		||||
			SendOK(w)
 | 
			
		||||
		} else {
 | 
			
		||||
			SendErrorResponse(w, "UPnP router not found")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get or set the enable state of the plugin
 | 
			
		||||
func handleEnableState(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if r.Method == "GET" {
 | 
			
		||||
		js, _ := json.Marshal(upnpRuntimeConfig.Enabled)
 | 
			
		||||
		SendJSONResponse(w, string(js))
 | 
			
		||||
	} else if r.Method == "POST" {
 | 
			
		||||
		enable, err := PostBool(r, "enable")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			SendErrorResponse(w, err.Error())
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !enable {
 | 
			
		||||
			//Close all the port forwards if UPnP client is available
 | 
			
		||||
			if upnpClient != nil {
 | 
			
		||||
				for _, record := range upnpRuntimeConfig.ForwardRules {
 | 
			
		||||
					err = upnpClient.ClosePort(record.PortNumber)
 | 
			
		||||
					if err != nil {
 | 
			
		||||
						SendErrorResponse(w, err.Error())
 | 
			
		||||
						return
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			if upnpClient == nil {
 | 
			
		||||
				SendErrorResponse(w, "No UPnP router in network")
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			//Forward all the ports if UPnP client is available
 | 
			
		||||
			if upnpClient != nil {
 | 
			
		||||
				for _, record := range upnpRuntimeConfig.ForwardRules {
 | 
			
		||||
					err = upnpClient.ForwardPort(record.PortNumber, record.RuleName)
 | 
			
		||||
					if err != nil {
 | 
			
		||||
						SendErrorResponse(w, err.Error())
 | 
			
		||||
						return
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		upnpRuntimeConfig.Enabled = enable
 | 
			
		||||
		SaveRuntimeConfig()
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func handleForwardPortEdit(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if r.Method == "POST" {
 | 
			
		||||
		port, err := PostInt(r, "port")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			SendErrorResponse(w, err.Error())
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		oldPort, err := PostInt(r, "oldPort")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			SendErrorResponse(w, err.Error())
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		name, err := PostPara(r, "name")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			SendErrorResponse(w, err.Error())
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if port < 1 || port > 65535 {
 | 
			
		||||
			SendErrorResponse(w, "invalid port number")
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Check if the old port exists
 | 
			
		||||
		found := false
 | 
			
		||||
		for _, record := range upnpRuntimeConfig.ForwardRules {
 | 
			
		||||
			if record.PortNumber == oldPort {
 | 
			
		||||
				found = true
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !found {
 | 
			
		||||
			SendErrorResponse(w, "editing forward rule not found")
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Delete the old port forward
 | 
			
		||||
		if oldPort != port && upnpClient != nil {
 | 
			
		||||
			//Remove the port forward if UPnP client is available
 | 
			
		||||
			err = upnpClient.ClosePort(oldPort)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				SendErrorResponse(w, err.Error())
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Remove from runtime config
 | 
			
		||||
		for i, record := range upnpRuntimeConfig.ForwardRules {
 | 
			
		||||
			if record.PortNumber == oldPort {
 | 
			
		||||
				upnpRuntimeConfig.ForwardRules = append(upnpRuntimeConfig.ForwardRules[:i], upnpRuntimeConfig.ForwardRules[i+1:]...)
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Create the new forward rule
 | 
			
		||||
		if upnpClient != nil {
 | 
			
		||||
			//Forward the port if UPnP client is available
 | 
			
		||||
			err = upnpClient.ForwardPort(port, name)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				SendErrorResponse(w, err.Error())
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Add to runtime config
 | 
			
		||||
		upnpRuntimeConfig.ForwardRules = append(upnpRuntimeConfig.ForwardRules, &PortForwardRecord{
 | 
			
		||||
			RuleName:   name,
 | 
			
		||||
			PortNumber: port,
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		//Save the runtime config
 | 
			
		||||
		SaveRuntimeConfig()
 | 
			
		||||
		SendOK(w)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Remove a port forward
 | 
			
		||||
func handleForwardPortRemove(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if r.Method == "POST" {
 | 
			
		||||
		port, err := PostInt(r, "port")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			SendErrorResponse(w, err.Error())
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if upnpClient != nil {
 | 
			
		||||
			//Remove the port forward if UPnP client is available
 | 
			
		||||
			err = upnpClient.ClosePort(port)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				SendErrorResponse(w, err.Error())
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Remove from runtime config
 | 
			
		||||
		for i, record := range upnpRuntimeConfig.ForwardRules {
 | 
			
		||||
			if record.PortNumber == port {
 | 
			
		||||
				upnpRuntimeConfig.ForwardRules = append(upnpRuntimeConfig.ForwardRules[:i], upnpRuntimeConfig.ForwardRules[i+1:]...)
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Save the runtime config
 | 
			
		||||
		SaveRuntimeConfig()
 | 
			
		||||
		SendOK(w)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Handle the port forward operations
 | 
			
		||||
func handleForwardPort(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if r.Method == "GET" {
 | 
			
		||||
		// List all the forwarded ports
 | 
			
		||||
		js, _ := json.Marshal(upnpRuntimeConfig.ForwardRules)
 | 
			
		||||
		SendJSONResponse(w, string(js))
 | 
			
		||||
	} else if r.Method == "POST" {
 | 
			
		||||
		//Add a new port forward
 | 
			
		||||
		port, err := PostInt(r, "port")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			SendErrorResponse(w, err.Error())
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		name, err := PostPara(r, "name")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			SendErrorResponse(w, err.Error())
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if port < 1 || port > 65535 {
 | 
			
		||||
			SendErrorResponse(w, "invalid port number")
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if upnpClient != nil {
 | 
			
		||||
			//Forward the port if UPnP client is available
 | 
			
		||||
			err = upnpClient.ForwardPort(port, name)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				SendErrorResponse(w, err.Error())
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Add to runtime config
 | 
			
		||||
		upnpRuntimeConfig.ForwardRules = append(upnpRuntimeConfig.ForwardRules, &PortForwardRecord{
 | 
			
		||||
			RuleName:   name,
 | 
			
		||||
			PortNumber: port,
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		//Save the runtime config
 | 
			
		||||
		SaveRuntimeConfig()
 | 
			
		||||
		SendOK(w)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
	Network Utilities
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
// Send JSON response, with an extra json header
 | 
			
		||||
func SendJSONResponse(w http.ResponseWriter, json string) {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	w.Write([]byte(json))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func SendErrorResponse(w http.ResponseWriter, errMsg string) {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	w.Write([]byte("{\"error\":\"" + errMsg + "\"}"))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func SendOK(w http.ResponseWriter) {
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	w.Write([]byte("\"OK\""))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get GET parameter
 | 
			
		||||
func GetPara(r *http.Request, key string) (string, error) {
 | 
			
		||||
	// Get first value from the URL query
 | 
			
		||||
	value := r.URL.Query().Get(key)
 | 
			
		||||
	if len(value) == 0 {
 | 
			
		||||
		return "", errors.New("invalid " + key + " given")
 | 
			
		||||
	}
 | 
			
		||||
	return value, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get GET paramter as boolean, accept 1 or true
 | 
			
		||||
func GetBool(r *http.Request, key string) (bool, error) {
 | 
			
		||||
	x, err := GetPara(r, key)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Convert to lowercase and trim spaces just once to compare
 | 
			
		||||
	switch strings.ToLower(strings.TrimSpace(x)) {
 | 
			
		||||
	case "1", "true", "on":
 | 
			
		||||
		return true, nil
 | 
			
		||||
	case "0", "false", "off":
 | 
			
		||||
		return false, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return false, errors.New("invalid boolean given")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get POST parameter
 | 
			
		||||
func PostPara(r *http.Request, key string) (string, error) {
 | 
			
		||||
	// Try to parse the form
 | 
			
		||||
	if err := r.ParseForm(); err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
	// Get first value from the form
 | 
			
		||||
	x := r.Form.Get(key)
 | 
			
		||||
	if len(x) == 0 {
 | 
			
		||||
		return "", errors.New("invalid " + key + " given")
 | 
			
		||||
	}
 | 
			
		||||
	return x, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get POST paramter as boolean, accept 1 or true
 | 
			
		||||
func PostBool(r *http.Request, key string) (bool, error) {
 | 
			
		||||
	x, err := PostPara(r, key)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Convert to lowercase and trim spaces just once to compare
 | 
			
		||||
	switch strings.ToLower(strings.TrimSpace(x)) {
 | 
			
		||||
	case "1", "true", "on":
 | 
			
		||||
		return true, nil
 | 
			
		||||
	case "0", "false", "off":
 | 
			
		||||
		return false, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return false, errors.New("invalid boolean given")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get POST paramter as int
 | 
			
		||||
func PostInt(r *http.Request, key string) (int, error) {
 | 
			
		||||
	x, err := PostPara(r, key)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	x = strings.TrimSpace(x)
 | 
			
		||||
	rx, err := strconv.Atoi(x)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return rx, nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								example/plugins/upnp/go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								example/plugins/upnp/go.mod
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
module plugins.zoraxy.aroz.org/zoraxy/upnp
 | 
			
		||||
 | 
			
		||||
go 1.23.6
 | 
			
		||||
 | 
			
		||||
require gitlab.com/NebulousLabs/go-upnp v0.0.0-20211002182029-11da932010b6
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40 // indirect
 | 
			
		||||
	golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect
 | 
			
		||||
	golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect
 | 
			
		||||
	golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 // indirect
 | 
			
		||||
	golang.org/x/text v0.3.6 // indirect
 | 
			
		||||
)
 | 
			
		||||
							
								
								
									
										17
									
								
								example/plugins/upnp/go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								example/plugins/upnp/go.sum
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40 h1:dizWJqTWjwyD8KGcMOwgrkqu1JIkofYgKkmDeNE7oAs=
 | 
			
		||||
gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40/go.mod h1:rOnSnoRyxMI3fe/7KIbVcsHRGxe30OONv8dEgo+vCfA=
 | 
			
		||||
gitlab.com/NebulousLabs/go-upnp v0.0.0-20211002182029-11da932010b6 h1:WKij6HF8ECp9E7K0E44dew9NrRDGiNR5u4EFsXnJUx4=
 | 
			
		||||
gitlab.com/NebulousLabs/go-upnp v0.0.0-20211002182029-11da932010b6/go.mod h1:vhrHTGDh4YR7wK8Z+kRJ+x8SF/6RUM3Vb64Si5FD0L8=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 | 
			
		||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 | 
			
		||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc=
 | 
			
		||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
 | 
			
		||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 h1:Bli41pIlzTzf3KEY06n+xnzK/BESIg2ze4Pgfh/aI8c=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 | 
			
		||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 | 
			
		||||
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
 | 
			
		||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 | 
			
		||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								example/plugins/upnp/icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								example/plugins/upnp/icon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 7.8 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								example/plugins/upnp/icon.psd
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								example/plugins/upnp/icon.psd
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										194
									
								
								example/plugins/upnp/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								example/plugins/upnp/main.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,194 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"embed"
 | 
			
		||||
	_ "embed"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"plugins.zoraxy.aroz.org/zoraxy/upnp/mod/upnpc"
 | 
			
		||||
	plugin "plugins.zoraxy.aroz.org/zoraxy/upnp/mod/zoraxy_plugin"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	PLUGIN_ID           = "org.aroz.zoraxy.plugins.upnp"
 | 
			
		||||
	UI_PATH             = "/ui"
 | 
			
		||||
	WEB_ROOT            = "/www"
 | 
			
		||||
	CONFIG_FILE         = "upnp.json"
 | 
			
		||||
	AUTO_RENEW_INTERVAL = 12 * 60 * 60 // 12 hours
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type PortForwardRecord struct {
 | 
			
		||||
	RuleName   string
 | 
			
		||||
	PortNumber int
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UPnPConfig struct {
 | 
			
		||||
	ForwardRules []*PortForwardRecord
 | 
			
		||||
	Enabled      bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//go:embed www/*
 | 
			
		||||
var content embed.FS
 | 
			
		||||
 | 
			
		||||
// Runtime variables
 | 
			
		||||
var (
 | 
			
		||||
	upnpRouterExists  bool        = false
 | 
			
		||||
	upnpRuntimeConfig *UPnPConfig = &UPnPConfig{
 | 
			
		||||
		ForwardRules: []*PortForwardRecord{},
 | 
			
		||||
		Enabled:      false,
 | 
			
		||||
	}
 | 
			
		||||
	upnpClient      *upnpc.UPnPClient = nil
 | 
			
		||||
	renewTickerStop chan bool
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	//Handle introspect
 | 
			
		||||
	runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{
 | 
			
		||||
		ID:            PLUGIN_ID,
 | 
			
		||||
		Name:          "UPnP Forwarder",
 | 
			
		||||
		Author:        "aroz.org",
 | 
			
		||||
		AuthorContact: "https://github.com/aroz-online",
 | 
			
		||||
		Description:   "A UPnP Port Forwarder Plugin for Zoraxy",
 | 
			
		||||
		URL:           "https://github.com/aroz-online",
 | 
			
		||||
		Type:          plugin.PluginType_Utilities,
 | 
			
		||||
		VersionMajor:  1,
 | 
			
		||||
		VersionMinor:  0,
 | 
			
		||||
		VersionPatch:  0,
 | 
			
		||||
		UIPath:        UI_PATH,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		//Terminate or enter standalone mode here
 | 
			
		||||
		fmt.Println("This is a plugin for Zoraxy and should not be run standalone\n Visit zoraxy.aroz.org to download Zoraxy.")
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Read the configuration from file
 | 
			
		||||
	if _, err := os.Stat(CONFIG_FILE); os.IsNotExist(err) {
 | 
			
		||||
		err = os.WriteFile(CONFIG_FILE, []byte("{}"), 0644)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			panic(err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cfgBytes, err := os.ReadFile(CONFIG_FILE)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Load the configuration
 | 
			
		||||
	err = json.Unmarshal(cfgBytes, &upnpRuntimeConfig)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Start upnp client and auto-renew ticker
 | 
			
		||||
	go func() {
 | 
			
		||||
		TryStartUPnPClient()
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	//Serve the plugin UI
 | 
			
		||||
	embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH)
 | 
			
		||||
	// For debugging, use the following line instead
 | 
			
		||||
	//embedWebRouter := plugin.NewPluginFileSystemUIRouter(PLUGIN_ID, "."+WEB_ROOT, UI_PATH)
 | 
			
		||||
	//embedWebRouter.EnableDebug = true
 | 
			
		||||
	embedWebRouter.RegisterTerminateHandler(func() {
 | 
			
		||||
		if renewTickerStop != nil {
 | 
			
		||||
			renewTickerStop <- true
 | 
			
		||||
		}
 | 
			
		||||
		// Do cleanup here if needed
 | 
			
		||||
		upnpClient.Close()
 | 
			
		||||
	}, nil)
 | 
			
		||||
	embedWebRouter.AttachHandlerToMux(nil)
 | 
			
		||||
 | 
			
		||||
	//Serve the API
 | 
			
		||||
	RegisterAPIs()
 | 
			
		||||
 | 
			
		||||
	//Start the IO server
 | 
			
		||||
	fmt.Println("UPnP Forwarder started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port))
 | 
			
		||||
	err = http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RegisterAPIs registers the APIs for the plugin
 | 
			
		||||
func RegisterAPIs() {
 | 
			
		||||
	http.HandleFunc(UI_PATH+"/api/usable", handleUsableState)
 | 
			
		||||
	http.HandleFunc(UI_PATH+"/api/enable", handleEnableState)
 | 
			
		||||
	http.HandleFunc(UI_PATH+"/api/forward", handleForwardPort)
 | 
			
		||||
	http.HandleFunc(UI_PATH+"/api/edit", handleForwardPortEdit)
 | 
			
		||||
	http.HandleFunc(UI_PATH+"/api/remove", handleForwardPortRemove)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TryStartUPnPClient tries to start the UPnP client
 | 
			
		||||
func TryStartUPnPClient() {
 | 
			
		||||
	if renewTickerStop != nil {
 | 
			
		||||
		renewTickerStop <- true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create UPnP client
 | 
			
		||||
	upnpClient, err := upnpc.NewUPNPClient()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		upnpRouterExists = false
 | 
			
		||||
		upnpRuntimeConfig.Enabled = false
 | 
			
		||||
		fmt.Println("UPnP router not found")
 | 
			
		||||
		SaveRuntimeConfig()
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	upnpRouterExists = true
 | 
			
		||||
 | 
			
		||||
	//Check if the client is enabled by default
 | 
			
		||||
	if upnpRuntimeConfig.Enabled {
 | 
			
		||||
		// Forward all the ports
 | 
			
		||||
		for _, rule := range upnpRuntimeConfig.ForwardRules {
 | 
			
		||||
			err = upnpClient.ForwardPort(rule.PortNumber, rule.RuleName)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				fmt.Println("Unable to forward port", rule.PortNumber, ":", err)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Start the auto-renew ticker
 | 
			
		||||
	_, renewTickerStop = SetupAutoRenewTicker()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetupAutoRenewTicker sets up a ticker for auto-renewing the port forwarding rules
 | 
			
		||||
func SetupAutoRenewTicker() (*time.Ticker, chan bool) {
 | 
			
		||||
	ticker := time.NewTicker(AUTO_RENEW_INTERVAL * time.Second)
 | 
			
		||||
	closeChan := make(chan bool)
 | 
			
		||||
	go func() {
 | 
			
		||||
		for {
 | 
			
		||||
			select {
 | 
			
		||||
			case <-closeChan:
 | 
			
		||||
				ticker.Stop()
 | 
			
		||||
				return
 | 
			
		||||
			case <-ticker.C:
 | 
			
		||||
				if upnpClient != nil {
 | 
			
		||||
					upnpClient.RenewForwardRules()
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
	return ticker, closeChan
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SaveRuntimeConfig saves the runtime configuration to file
 | 
			
		||||
func SaveRuntimeConfig() error {
 | 
			
		||||
	cfgBytes, err := json.Marshal(upnpRuntimeConfig)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = os.WriteFile(CONFIG_FILE, cfgBytes, 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										135
									
								
								example/plugins/upnp/mod/upnpc/upnpc.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								example/plugins/upnp/mod/upnpc/upnpc.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,135 @@
 | 
			
		||||
package upnpc
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"gitlab.com/NebulousLabs/go-upnp"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
	uPNP Module
 | 
			
		||||
 | 
			
		||||
	This module handles uPNP Connections to the gateway router and create a port forward entry
 | 
			
		||||
	for the host system at the given port (set with -port paramter)
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
type UPnPClient struct {
 | 
			
		||||
	Connection    *upnp.IGD //UPnP conenction object
 | 
			
		||||
	ExternalIP    string    //Storage of external IP address
 | 
			
		||||
	RequiredPorts []int     //All the required ports will be recored
 | 
			
		||||
	PolicyNames   sync.Map  //Name for the required port nubmer
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewUPNPClient creates a new UPnPClient object
 | 
			
		||||
func NewUPNPClient() (*UPnPClient, error) {
 | 
			
		||||
	//Create uPNP forwarding in the NAT router
 | 
			
		||||
	fmt.Println("Discovering UPnP router in Local Area Network...")
 | 
			
		||||
	d, err := upnp.Discover()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return &UPnPClient{}, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// discover external IP
 | 
			
		||||
	ip, err := d.ExternalIP()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return &UPnPClient{}, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Create the final obejcts
 | 
			
		||||
	newUPnPObject := &UPnPClient{
 | 
			
		||||
		Connection:    d,
 | 
			
		||||
		ExternalIP:    ip,
 | 
			
		||||
		RequiredPorts: []int{},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return newUPnPObject, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ForwardPort forwards a port to the host
 | 
			
		||||
func (u *UPnPClient) ForwardPort(portNumber int, ruleName string) error {
 | 
			
		||||
	fmt.Println("UPnP forwarding new port: ", portNumber, "for "+ruleName+" service")
 | 
			
		||||
 | 
			
		||||
	//Check if port already forwarded
 | 
			
		||||
	_, ok := u.PolicyNames.Load(portNumber)
 | 
			
		||||
	if ok {
 | 
			
		||||
		//Port already forward. Ignore this request
 | 
			
		||||
		return errors.New("port already forwarded")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// forward a port
 | 
			
		||||
	err := u.Connection.Forward(uint16(portNumber), ruleName)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	u.RequiredPorts = append(u.RequiredPorts, portNumber)
 | 
			
		||||
	u.PolicyNames.Store(portNumber, ruleName)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ClosePort closes the port forwarding
 | 
			
		||||
func (u *UPnPClient) ClosePort(portNumber int) error {
 | 
			
		||||
	//Check if port is opened
 | 
			
		||||
	portOpened := false
 | 
			
		||||
	newRequiredPort := []int{}
 | 
			
		||||
	for _, thisPort := range u.RequiredPorts {
 | 
			
		||||
		if thisPort != portNumber {
 | 
			
		||||
			newRequiredPort = append(newRequiredPort, thisPort)
 | 
			
		||||
		} else {
 | 
			
		||||
			portOpened = true
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if portOpened {
 | 
			
		||||
		//Update the port list
 | 
			
		||||
		u.RequiredPorts = newRequiredPort
 | 
			
		||||
 | 
			
		||||
		// Close the port
 | 
			
		||||
		fmt.Println("Closing UPnP Port Forward: ", portNumber)
 | 
			
		||||
		err := u.Connection.Clear(uint16(portNumber))
 | 
			
		||||
 | 
			
		||||
		//Delete the name registry
 | 
			
		||||
		u.PolicyNames.Delete(portNumber)
 | 
			
		||||
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Println(err)
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Renew forward rules, prevent router lease time from flushing the Upnp config
 | 
			
		||||
func (u *UPnPClient) RenewForwardRules() {
 | 
			
		||||
	if u.Connection == nil {
 | 
			
		||||
		//UPnP router gone
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	portsToRenew := u.RequiredPorts
 | 
			
		||||
	for _, thisPort := range portsToRenew {
 | 
			
		||||
		ruleName, ok := u.PolicyNames.Load(thisPort)
 | 
			
		||||
		if !ok {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		u.ClosePort(thisPort)
 | 
			
		||||
		time.Sleep(100 * time.Millisecond)
 | 
			
		||||
		u.ForwardPort(thisPort, ruleName.(string))
 | 
			
		||||
	}
 | 
			
		||||
	fmt.Println("UPnP Port Forward rule renew completed")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (u *UPnPClient) Close() error {
 | 
			
		||||
	//Shutdown the default UPnP Object
 | 
			
		||||
	if u != nil {
 | 
			
		||||
		for _, portNumber := range u.RequiredPorts {
 | 
			
		||||
			err := u.Connection.Clear(uint16(portNumber))
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										19
									
								
								example/plugins/upnp/mod/zoraxy_plugin/README.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								example/plugins/upnp/mod/zoraxy_plugin/README.txt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
# Zoraxy Plugin
 | 
			
		||||
 | 
			
		||||
## Overview
 | 
			
		||||
This module serves as a template for building your own plugins for the Zoraxy Reverse Proxy. By copying this module to your plugin mod folder, you can create a new plugin with the necessary structure and components.
 | 
			
		||||
 | 
			
		||||
## Instructions
 | 
			
		||||
 | 
			
		||||
1. **Copy the Module:**
 | 
			
		||||
    - Copy the entire `zoraxy_plugin` module to your plugin mod folder.
 | 
			
		||||
 | 
			
		||||
2. **Include the Structure:**
 | 
			
		||||
    - Ensure that you maintain the directory structure and file organization as provided in this module.
 | 
			
		||||
 | 
			
		||||
3. **Modify as Needed:**
 | 
			
		||||
    - Customize the copied module to implement the desired functionality for your plugin.
 | 
			
		||||
 | 
			
		||||
## Directory Structure
 | 
			
		||||
 zoraxy_plugin: Handle -introspect and -configuration process required for plugin loading and startup
 | 
			
		||||
 embed_webserver: Handle embeded web server routing and injecting csrf token to your plugin served UI pages
 | 
			
		||||
							
								
								
									
										145
									
								
								example/plugins/upnp/mod/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								example/plugins/upnp/mod/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,145 @@
 | 
			
		||||
package zoraxy_plugin
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type PluginUiDebugRouter struct {
 | 
			
		||||
	PluginID         string //The ID of the plugin
 | 
			
		||||
	TargetDir        string //The directory where the UI files are stored
 | 
			
		||||
	HandlerPrefix    string //The prefix of the handler used to route this router, e.g. /ui
 | 
			
		||||
	EnableDebug      bool   //Enable debug mode
 | 
			
		||||
	terminateHandler func() //The handler to be called when the plugin is terminated
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
 | 
			
		||||
// The targetDir is the directory where the UI files are stored (e.g. ./www)
 | 
			
		||||
// The handlerPrefix is the prefix of the handler used to route this router
 | 
			
		||||
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
 | 
			
		||||
// All prefix should not end with a slash
 | 
			
		||||
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
 | 
			
		||||
	//Make sure all prefix are in /prefix format
 | 
			
		||||
	if !strings.HasPrefix(handlerPrefix, "/") {
 | 
			
		||||
		handlerPrefix = "/" + handlerPrefix
 | 
			
		||||
	}
 | 
			
		||||
	handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
 | 
			
		||||
 | 
			
		||||
	//Return the PluginUiRouter
 | 
			
		||||
	return &PluginUiDebugRouter{
 | 
			
		||||
		PluginID:      pluginID,
 | 
			
		||||
		TargetDir:     targetDir,
 | 
			
		||||
		HandlerPrefix: handlerPrefix,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
 | 
			
		||||
	//Get the CSRF token from header
 | 
			
		||||
	csrfToken := r.Header.Get("X-Zoraxy-Csrf")
 | 
			
		||||
	if csrfToken == "" {
 | 
			
		||||
		csrfToken = "missing-csrf-token"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Return the middleware
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		// Check if the request is for an HTML file
 | 
			
		||||
		if strings.HasSuffix(r.URL.Path, ".html") {
 | 
			
		||||
			//Read the target file from file system
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			body := string(targetFileContent)
 | 
			
		||||
			body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
			w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			//Check if the request is for a directory
 | 
			
		||||
			//Check if the directory has an index.html file
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			if _, err := os.Stat(targetFilePath); err == nil {
 | 
			
		||||
				//Serve the index.html file
 | 
			
		||||
				targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
				body := string(targetFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
		fsHandler.ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetHttpHandler returns the http.Handler for the PluginUiRouter
 | 
			
		||||
func (p *PluginUiDebugRouter) Handler() http.Handler {
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		//Remove the plugin UI handler path prefix
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rewrittenURL := r.RequestURI
 | 
			
		||||
		rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
 | 
			
		||||
		rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
 | 
			
		||||
		r.URL.Path = rewrittenURL
 | 
			
		||||
		r.RequestURI = rewrittenURL
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Println(r.URL.Path)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Serve the file from the file system
 | 
			
		||||
		fsHandler := http.FileServer(http.Dir(p.TargetDir))
 | 
			
		||||
 | 
			
		||||
		// Replace {{csrf_token}} with the actual CSRF token and serve the file
 | 
			
		||||
		p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
 | 
			
		||||
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
 | 
			
		||||
// if mux is nil, the handler will be registered to http.DefaultServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
 | 
			
		||||
	p.terminateHandler = termFunc
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
	mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		p.terminateHandler()
 | 
			
		||||
		w.WriteHeader(http.StatusOK)
 | 
			
		||||
		go func() {
 | 
			
		||||
			//Make sure the response is sent before the plugin is terminated
 | 
			
		||||
			time.Sleep(100 * time.Millisecond)
 | 
			
		||||
			os.Exit(0)
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the file system UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										162
									
								
								example/plugins/upnp/mod/zoraxy_plugin/dynamic_router.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								example/plugins/upnp/mod/zoraxy_plugin/dynamic_router.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,162 @@
 | 
			
		||||
package zoraxy_plugin
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 | 
			
		||||
	Dynamic Path Handler
 | 
			
		||||
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
type SniffResult int
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	SniffResultAccpet SniffResult = iota // Forward the request to this plugin dynamic capture ingress
 | 
			
		||||
	SniffResultSkip                      // Skip this plugin and let the next plugin handle the request
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type SniffHandler func(*DynamicSniffForwardRequest) SniffResult
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
RegisterDynamicSniffHandler registers a dynamic sniff handler for a path
 | 
			
		||||
You can decide to accept or skip the request based on the request header and paths
 | 
			
		||||
*/
 | 
			
		||||
func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http.ServeMux, handler SniffHandler) {
 | 
			
		||||
	if !strings.HasSuffix(sniff_ingress, "/") {
 | 
			
		||||
		sniff_ingress = sniff_ingress + "/"
 | 
			
		||||
	}
 | 
			
		||||
	mux.Handle(sniff_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		if p.enableDebugPrint {
 | 
			
		||||
			fmt.Println("Request captured by dynamic sniff path: " + r.RequestURI)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Decode the request payload
 | 
			
		||||
		jsonBytes, err := io.ReadAll(r.Body)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			if p.enableDebugPrint {
 | 
			
		||||
				fmt.Println("Error reading request body:", err)
 | 
			
		||||
			}
 | 
			
		||||
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		payload, err := DecodeForwardRequestPayload(jsonBytes)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			if p.enableDebugPrint {
 | 
			
		||||
				fmt.Println("Error decoding request payload:", err)
 | 
			
		||||
				fmt.Print("Payload: ")
 | 
			
		||||
				fmt.Println(string(jsonBytes))
 | 
			
		||||
			}
 | 
			
		||||
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get the forwarded request UUID
 | 
			
		||||
		forwardUUID := r.Header.Get("X-Zoraxy-RequestID")
 | 
			
		||||
		payload.requestUUID = forwardUUID
 | 
			
		||||
		payload.rawRequest = r
 | 
			
		||||
 | 
			
		||||
		sniffResult := handler(&payload)
 | 
			
		||||
		if sniffResult == SniffResultAccpet {
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte("OK"))
 | 
			
		||||
		} else {
 | 
			
		||||
			w.WriteHeader(http.StatusNotImplemented)
 | 
			
		||||
			w.Write([]byte("SKIP"))
 | 
			
		||||
		}
 | 
			
		||||
	}))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RegisterDynamicCaptureHandle register the dynamic capture ingress path with a handler
 | 
			
		||||
func (p *PathRouter) RegisterDynamicCaptureHandle(capture_ingress string, mux *http.ServeMux, handlefunc func(http.ResponseWriter, *http.Request)) {
 | 
			
		||||
	if !strings.HasSuffix(capture_ingress, "/") {
 | 
			
		||||
		capture_ingress = capture_ingress + "/"
 | 
			
		||||
	}
 | 
			
		||||
	mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		if p.enableDebugPrint {
 | 
			
		||||
			fmt.Println("Request captured by dynamic capture path: " + r.RequestURI)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rewrittenURL := r.RequestURI
 | 
			
		||||
		rewrittenURL = strings.TrimPrefix(rewrittenURL, capture_ingress)
 | 
			
		||||
		rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
 | 
			
		||||
		if rewrittenURL == "" {
 | 
			
		||||
			rewrittenURL = "/"
 | 
			
		||||
		}
 | 
			
		||||
		if !strings.HasPrefix(rewrittenURL, "/") {
 | 
			
		||||
			rewrittenURL = "/" + rewrittenURL
 | 
			
		||||
		}
 | 
			
		||||
		r.RequestURI = rewrittenURL
 | 
			
		||||
 | 
			
		||||
		handlefunc(w, r)
 | 
			
		||||
	}))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
	Sniffing and forwarding
 | 
			
		||||
 | 
			
		||||
	The following functions are here to help with
 | 
			
		||||
	sniffing and forwarding requests to the dynamic
 | 
			
		||||
	router.
 | 
			
		||||
*/
 | 
			
		||||
// A custom request object to be used in the dynamic sniffing
 | 
			
		||||
type DynamicSniffForwardRequest struct {
 | 
			
		||||
	Method     string              `json:"method"`
 | 
			
		||||
	Hostname   string              `json:"hostname"`
 | 
			
		||||
	URL        string              `json:"url"`
 | 
			
		||||
	Header     map[string][]string `json:"header"`
 | 
			
		||||
	RemoteAddr string              `json:"remote_addr"`
 | 
			
		||||
	Host       string              `json:"host"`
 | 
			
		||||
	RequestURI string              `json:"request_uri"`
 | 
			
		||||
	Proto      string              `json:"proto"`
 | 
			
		||||
	ProtoMajor int                 `json:"proto_major"`
 | 
			
		||||
	ProtoMinor int                 `json:"proto_minor"`
 | 
			
		||||
 | 
			
		||||
	/* Internal use */
 | 
			
		||||
	rawRequest  *http.Request `json:"-"`
 | 
			
		||||
	requestUUID string        `json:"-"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetForwardRequestPayload returns a DynamicSniffForwardRequest object from an http.Request object
 | 
			
		||||
func EncodeForwardRequestPayload(r *http.Request) DynamicSniffForwardRequest {
 | 
			
		||||
	return DynamicSniffForwardRequest{
 | 
			
		||||
		Method:     r.Method,
 | 
			
		||||
		Hostname:   r.Host,
 | 
			
		||||
		URL:        r.URL.String(),
 | 
			
		||||
		Header:     r.Header,
 | 
			
		||||
		RemoteAddr: r.RemoteAddr,
 | 
			
		||||
		Host:       r.Host,
 | 
			
		||||
		RequestURI: r.RequestURI,
 | 
			
		||||
		Proto:      r.Proto,
 | 
			
		||||
		ProtoMajor: r.ProtoMajor,
 | 
			
		||||
		ProtoMinor: r.ProtoMinor,
 | 
			
		||||
		rawRequest: r,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DecodeForwardRequestPayload decodes JSON bytes into a DynamicSniffForwardRequest object
 | 
			
		||||
func DecodeForwardRequestPayload(jsonBytes []byte) (DynamicSniffForwardRequest, error) {
 | 
			
		||||
	var payload DynamicSniffForwardRequest
 | 
			
		||||
	err := json.Unmarshal(jsonBytes, &payload)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return DynamicSniffForwardRequest{}, err
 | 
			
		||||
	}
 | 
			
		||||
	return payload, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetRequest returns the original http.Request object, for debugging purposes
 | 
			
		||||
func (dsfr *DynamicSniffForwardRequest) GetRequest() *http.Request {
 | 
			
		||||
	return dsfr.rawRequest
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetRequestUUID returns the request UUID
 | 
			
		||||
// if this UUID is empty string, that might indicate the request
 | 
			
		||||
// is not coming from the dynamic router
 | 
			
		||||
func (dsfr *DynamicSniffForwardRequest) GetRequestUUID() string {
 | 
			
		||||
	return dsfr.requestUUID
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										156
									
								
								example/plugins/upnp/mod/zoraxy_plugin/embed_webserver.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								example/plugins/upnp/mod/zoraxy_plugin/embed_webserver.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,156 @@
 | 
			
		||||
package zoraxy_plugin
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"embed"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io/fs"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type PluginUiRouter struct {
 | 
			
		||||
	PluginID         string    //The ID of the plugin
 | 
			
		||||
	TargetFs         *embed.FS //The embed.FS where the UI files are stored
 | 
			
		||||
	TargetFsPrefix   string    //The prefix of the embed.FS where the UI files are stored, e.g. /web
 | 
			
		||||
	HandlerPrefix    string    //The prefix of the handler used to route this router, e.g. /ui
 | 
			
		||||
	EnableDebug      bool      //Enable debug mode
 | 
			
		||||
	terminateHandler func()    //The handler to be called when the plugin is terminated
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS
 | 
			
		||||
// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored
 | 
			
		||||
// The targetFsPrefix should be relative to the root of the embed.FS
 | 
			
		||||
// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS
 | 
			
		||||
// The handlerPrefix is the prefix of the handler used to route this router
 | 
			
		||||
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
 | 
			
		||||
// All prefix should not end with a slash
 | 
			
		||||
func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter {
 | 
			
		||||
	//Make sure all prefix are in /prefix format
 | 
			
		||||
	if !strings.HasPrefix(targetFsPrefix, "/") {
 | 
			
		||||
		targetFsPrefix = "/" + targetFsPrefix
 | 
			
		||||
	}
 | 
			
		||||
	targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/")
 | 
			
		||||
 | 
			
		||||
	if !strings.HasPrefix(handlerPrefix, "/") {
 | 
			
		||||
		handlerPrefix = "/" + handlerPrefix
 | 
			
		||||
	}
 | 
			
		||||
	handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
 | 
			
		||||
 | 
			
		||||
	//Return the PluginUiRouter
 | 
			
		||||
	return &PluginUiRouter{
 | 
			
		||||
		PluginID:       pluginID,
 | 
			
		||||
		TargetFs:       targetFs,
 | 
			
		||||
		TargetFsPrefix: targetFsPrefix,
 | 
			
		||||
		HandlerPrefix:  handlerPrefix,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
 | 
			
		||||
	//Get the CSRF token from header
 | 
			
		||||
	csrfToken := r.Header.Get("X-Zoraxy-Csrf")
 | 
			
		||||
	if csrfToken == "" {
 | 
			
		||||
		csrfToken = "missing-csrf-token"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Return the middleware
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		// Check if the request is for an HTML file
 | 
			
		||||
		if strings.HasSuffix(r.URL.Path, ".html") {
 | 
			
		||||
			//Read the target file from embed.FS
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetFsPrefix + "/" + targetFilePath
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			body := string(targetFileContent)
 | 
			
		||||
			body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
			w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			// Check if the directory has an index.html file
 | 
			
		||||
			indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html"
 | 
			
		||||
			indexFilePath = p.TargetFsPrefix + "/" + indexFilePath
 | 
			
		||||
			indexFilePath = strings.TrimPrefix(indexFilePath, "/")
 | 
			
		||||
			indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				body := string(indexFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
		fsHandler.ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetHttpHandler returns the http.Handler for the PluginUiRouter
 | 
			
		||||
func (p *PluginUiRouter) Handler() http.Handler {
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		//Remove the plugin UI handler path prefix
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rewrittenURL := r.RequestURI
 | 
			
		||||
		rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
 | 
			
		||||
		rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
 | 
			
		||||
		r.URL, _ = url.Parse(rewrittenURL)
 | 
			
		||||
		r.RequestURI = rewrittenURL
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Println(r.URL.Path)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Serve the file from the embed.FS
 | 
			
		||||
		subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/"))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			fmt.Println(err.Error())
 | 
			
		||||
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Replace {{csrf_token}} with the actual CSRF token and serve the file
 | 
			
		||||
		p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
 | 
			
		||||
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
 | 
			
		||||
// if mux is nil, the handler will be registered to http.DefaultServeMux
 | 
			
		||||
func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
 | 
			
		||||
	p.terminateHandler = termFunc
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
	mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		p.terminateHandler()
 | 
			
		||||
		w.WriteHeader(http.StatusOK)
 | 
			
		||||
		go func() {
 | 
			
		||||
			//Make sure the response is sent before the plugin is terminated
 | 
			
		||||
			time.Sleep(100 * time.Millisecond)
 | 
			
		||||
			os.Exit(0)
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the embed UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										105
									
								
								example/plugins/upnp/mod/zoraxy_plugin/static_router.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								example/plugins/upnp/mod/zoraxy_plugin/static_router.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,105 @@
 | 
			
		||||
package zoraxy_plugin
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type PathRouter struct {
 | 
			
		||||
	enableDebugPrint bool
 | 
			
		||||
	pathHandlers     map[string]http.Handler
 | 
			
		||||
	defaultHandler   http.Handler
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewPathRouter creates a new PathRouter
 | 
			
		||||
func NewPathRouter() *PathRouter {
 | 
			
		||||
	return &PathRouter{
 | 
			
		||||
		enableDebugPrint: false,
 | 
			
		||||
		pathHandlers:     make(map[string]http.Handler),
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RegisterPathHandler registers a handler for a path
 | 
			
		||||
func (p *PathRouter) RegisterPathHandler(path string, handler http.Handler) {
 | 
			
		||||
	path = strings.TrimSuffix(path, "/")
 | 
			
		||||
	p.pathHandlers[path] = handler
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RemovePathHandler removes a handler for a path
 | 
			
		||||
func (p *PathRouter) RemovePathHandler(path string) {
 | 
			
		||||
	delete(p.pathHandlers, path)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetDefaultHandler sets the default handler for the router
 | 
			
		||||
// This handler will be called if no path handler is found
 | 
			
		||||
func (p *PathRouter) SetDefaultHandler(handler http.Handler) {
 | 
			
		||||
	p.defaultHandler = handler
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetDebugPrintMode sets the debug print mode
 | 
			
		||||
func (p *PathRouter) SetDebugPrintMode(enable bool) {
 | 
			
		||||
	p.enableDebugPrint = enable
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// StartStaticCapture starts the static capture ingress
 | 
			
		||||
func (p *PathRouter) RegisterStaticCaptureHandle(capture_ingress string, mux *http.ServeMux) {
 | 
			
		||||
	if !strings.HasSuffix(capture_ingress, "/") {
 | 
			
		||||
		capture_ingress = capture_ingress + "/"
 | 
			
		||||
	}
 | 
			
		||||
	mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		p.staticCaptureServeHTTP(w, r)
 | 
			
		||||
	}))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// staticCaptureServeHTTP serves the static capture path using user defined handler
 | 
			
		||||
func (p *PathRouter) staticCaptureServeHTTP(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	capturePath := r.Header.Get("X-Zoraxy-Capture")
 | 
			
		||||
	if capturePath != "" {
 | 
			
		||||
		if p.enableDebugPrint {
 | 
			
		||||
			fmt.Printf("Using capture path: %s\n", capturePath)
 | 
			
		||||
		}
 | 
			
		||||
		originalURI := r.Header.Get("X-Zoraxy-Uri")
 | 
			
		||||
		r.URL.Path = originalURI
 | 
			
		||||
		if handler, ok := p.pathHandlers[capturePath]; ok {
 | 
			
		||||
			handler.ServeHTTP(w, r)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	p.defaultHandler.ServeHTTP(w, r)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p *PathRouter) PrintRequestDebugMessage(r *http.Request) {
 | 
			
		||||
	if p.enableDebugPrint {
 | 
			
		||||
		fmt.Printf("Capture Request with path: %s \n\n**Request Headers** \n\n", r.URL.Path)
 | 
			
		||||
		keys := make([]string, 0, len(r.Header))
 | 
			
		||||
		for key := range r.Header {
 | 
			
		||||
			keys = append(keys, key)
 | 
			
		||||
		}
 | 
			
		||||
		sort.Strings(keys)
 | 
			
		||||
		for _, key := range keys {
 | 
			
		||||
			for _, value := range r.Header[key] {
 | 
			
		||||
				fmt.Printf("%s: %s\n", key, value)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Printf("\n\n**Request Details**\n\n")
 | 
			
		||||
		fmt.Printf("Method: %s\n", r.Method)
 | 
			
		||||
		fmt.Printf("URL: %s\n", r.URL.String())
 | 
			
		||||
		fmt.Printf("Proto: %s\n", r.Proto)
 | 
			
		||||
		fmt.Printf("Host: %s\n", r.Host)
 | 
			
		||||
		fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr)
 | 
			
		||||
		fmt.Printf("RequestURI: %s\n", r.RequestURI)
 | 
			
		||||
		fmt.Printf("ContentLength: %d\n", r.ContentLength)
 | 
			
		||||
		fmt.Printf("TransferEncoding: %v\n", r.TransferEncoding)
 | 
			
		||||
		fmt.Printf("Close: %v\n", r.Close)
 | 
			
		||||
		fmt.Printf("Form: %v\n", r.Form)
 | 
			
		||||
		fmt.Printf("PostForm: %v\n", r.PostForm)
 | 
			
		||||
		fmt.Printf("MultipartForm: %v\n", r.MultipartForm)
 | 
			
		||||
		fmt.Printf("Trailer: %v\n", r.Trailer)
 | 
			
		||||
		fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr)
 | 
			
		||||
		fmt.Printf("RequestURI: %s\n", r.RequestURI)
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										175
									
								
								example/plugins/upnp/mod/zoraxy_plugin/zoraxy_plugin.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								example/plugins/upnp/mod/zoraxy_plugin/zoraxy_plugin.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,175 @@
 | 
			
		||||
package zoraxy_plugin
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
	Plugins Includes.go
 | 
			
		||||
 | 
			
		||||
	This file is copied from Zoraxy source code
 | 
			
		||||
	You can always find the latest version under mod/plugins/includes.go
 | 
			
		||||
	Usually this file are backward compatible
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
type PluginType int
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	PluginType_Router    PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic
 | 
			
		||||
	PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type StaticCaptureRule struct {
 | 
			
		||||
	CapturePath string `json:"capture_path"`
 | 
			
		||||
	//To be expanded
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ControlStatusCode int
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	ControlStatusCode_CAPTURED  ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic
 | 
			
		||||
	ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic
 | 
			
		||||
	ControlStatusCode_ERROR     ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type SubscriptionEvent struct {
 | 
			
		||||
	EventName   string `json:"event_name"`
 | 
			
		||||
	EventSource string `json:"event_source"`
 | 
			
		||||
	Payload     string `json:"payload"` //Payload of the event, can be empty
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type RuntimeConstantValue struct {
 | 
			
		||||
	ZoraxyVersion string `json:"zoraxy_version"`
 | 
			
		||||
	ZoraxyUUID    string `json:"zoraxy_uuid"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
IntroSpect Payload
 | 
			
		||||
 | 
			
		||||
When the plugin is initialized with -introspect flag,
 | 
			
		||||
the plugin shell return this payload as JSON and exit
 | 
			
		||||
*/
 | 
			
		||||
type IntroSpect struct {
 | 
			
		||||
	/* Plugin metadata */
 | 
			
		||||
	ID            string     `json:"id"`             //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname
 | 
			
		||||
	Name          string     `json:"name"`           //Name of your plugin
 | 
			
		||||
	Author        string     `json:"author"`         //Author name of your plugin
 | 
			
		||||
	AuthorContact string     `json:"author_contact"` //Author contact of your plugin, like email
 | 
			
		||||
	Description   string     `json:"description"`    //Description of your plugin
 | 
			
		||||
	URL           string     `json:"url"`            //URL of your plugin
 | 
			
		||||
	Type          PluginType `json:"type"`           //Type of your plugin, Router(0) or Utilities(1)
 | 
			
		||||
	VersionMajor  int        `json:"version_major"`  //Major version of your plugin
 | 
			
		||||
	VersionMinor  int        `json:"version_minor"`  //Minor version of your plugin
 | 
			
		||||
	VersionPatch  int        `json:"version_patch"`  //Patch version of your plugin
 | 
			
		||||
 | 
			
		||||
	/*
 | 
			
		||||
 | 
			
		||||
		Endpoint Settings
 | 
			
		||||
 | 
			
		||||
	*/
 | 
			
		||||
 | 
			
		||||
	/*
 | 
			
		||||
		Static Capture Settings
 | 
			
		||||
 | 
			
		||||
		Once plugin is enabled these rules always applies to the enabled HTTP Proxy rule
 | 
			
		||||
		This is faster than dynamic capture, but less flexible
 | 
			
		||||
	*/
 | 
			
		||||
	StaticCapturePaths   []StaticCaptureRule `json:"static_capture_paths"`   //Static capture paths of your plugin, see Zoraxy documentation for more details
 | 
			
		||||
	StaticCaptureIngress string              `json:"static_capture_ingress"` //Static capture ingress path of your plugin (e.g. /s_handler)
 | 
			
		||||
 | 
			
		||||
	/*
 | 
			
		||||
		Dynamic Capture Settings
 | 
			
		||||
 | 
			
		||||
		Once plugin is enabled, these rules will be captured and forward to plugin sniff
 | 
			
		||||
		if the plugin sniff returns 280, the traffic will be captured
 | 
			
		||||
		otherwise, the traffic will be forwarded to the next plugin
 | 
			
		||||
		This is slower than static capture, but more flexible
 | 
			
		||||
	*/
 | 
			
		||||
	DynamicCaptureSniff   string `json:"dynamic_capture_sniff"`   //Dynamic capture sniff path of your plugin (e.g. /d_sniff)
 | 
			
		||||
	DynamicCaptureIngress string `json:"dynamic_capture_ingress"` //Dynamic capture ingress path of your plugin (e.g. /d_handler)
 | 
			
		||||
 | 
			
		||||
	/* UI Path for your plugin */
 | 
			
		||||
	UIPath string `json:"ui_path"` //UI path of your plugin (e.g. /ui), will proxy the whole subpath tree to Zoraxy Web UI as plugin UI
 | 
			
		||||
 | 
			
		||||
	/* Subscriptions Settings */
 | 
			
		||||
	SubscriptionPath    string            `json:"subscription_path"`    //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered
 | 
			
		||||
	SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, see Zoraxy documentation for more details
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
ServeIntroSpect Function
 | 
			
		||||
 | 
			
		||||
This function will check if the plugin is initialized with -introspect flag,
 | 
			
		||||
if so, it will print the intro spect and exit
 | 
			
		||||
 | 
			
		||||
Place this function at the beginning of your plugin main function
 | 
			
		||||
*/
 | 
			
		||||
func ServeIntroSpect(pluginSpect *IntroSpect) {
 | 
			
		||||
	if len(os.Args) > 1 && os.Args[1] == "-introspect" {
 | 
			
		||||
		//Print the intro spect and exit
 | 
			
		||||
		jsonData, _ := json.MarshalIndent(pluginSpect, "", " ")
 | 
			
		||||
		fmt.Println(string(jsonData))
 | 
			
		||||
		os.Exit(0)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
ConfigureSpec Payload
 | 
			
		||||
 | 
			
		||||
Zoraxy will start your plugin with -configure flag,
 | 
			
		||||
the plugin shell read this payload as JSON and configure itself
 | 
			
		||||
by the supplied values like starting a web server at given port
 | 
			
		||||
that listens to 127.0.0.1:port
 | 
			
		||||
*/
 | 
			
		||||
type ConfigureSpec struct {
 | 
			
		||||
	Port         int                  `json:"port"`          //Port to listen
 | 
			
		||||
	RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values
 | 
			
		||||
	//To be expanded
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
RecvExecuteConfigureSpec Function
 | 
			
		||||
 | 
			
		||||
This function will read the configure spec from Zoraxy
 | 
			
		||||
and return the ConfigureSpec object
 | 
			
		||||
 | 
			
		||||
Place this function after ServeIntroSpect function in your plugin main function
 | 
			
		||||
*/
 | 
			
		||||
func RecvConfigureSpec() (*ConfigureSpec, error) {
 | 
			
		||||
	for i, arg := range os.Args {
 | 
			
		||||
		if strings.HasPrefix(arg, "-configure=") {
 | 
			
		||||
			var configSpec ConfigureSpec
 | 
			
		||||
			if err := json.Unmarshal([]byte(arg[11:]), &configSpec); err != nil {
 | 
			
		||||
				return nil, err
 | 
			
		||||
			}
 | 
			
		||||
			return &configSpec, nil
 | 
			
		||||
		} else if arg == "-configure" {
 | 
			
		||||
			var configSpec ConfigureSpec
 | 
			
		||||
			var nextArg string
 | 
			
		||||
			if len(os.Args) > i+1 {
 | 
			
		||||
				nextArg = os.Args[i+1]
 | 
			
		||||
				if err := json.Unmarshal([]byte(nextArg), &configSpec); err != nil {
 | 
			
		||||
					return nil, err
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				return nil, fmt.Errorf("No port specified after -configure flag")
 | 
			
		||||
			}
 | 
			
		||||
			return &configSpec, nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil, fmt.Errorf("No -configure flag found")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
ServeAndRecvSpec Function
 | 
			
		||||
 | 
			
		||||
This function will serve the intro spect and return the configure spec
 | 
			
		||||
See the ServeIntroSpect and RecvConfigureSpec for more details
 | 
			
		||||
*/
 | 
			
		||||
func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) {
 | 
			
		||||
	ServeIntroSpect(pluginSpect)
 | 
			
		||||
	return RecvConfigureSpec()
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								example/plugins/upnp/upnp.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								example/plugins/upnp/upnp.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
{"ForwardRules":[{"RuleName":"EarlySpring","PortNumber":2016}],"Enabled":false}
 | 
			
		||||
							
								
								
									
										302
									
								
								example/plugins/upnp/www/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								example/plugins/upnp/www/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,302 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
    <meta charset="UTF-8">
 | 
			
		||||
    <!-- CSRF token, if your plugin need to make POST request to backend -->
 | 
			
		||||
    <meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">
 | 
			
		||||
    <title>UPnP Port Forwarder | Zoraxy</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>
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
    <link rel="stylesheet" href="/main.css">
 | 
			
		||||
    <style>
 | 
			
		||||
        body {
 | 
			
		||||
            background:none;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
    <!-- Dark theme script must be included after body tag-->
 | 
			
		||||
    <link rel="stylesheet" href="/darktheme.css">
 | 
			
		||||
    <script src="/script/darktheme.js"></script>
 | 
			
		||||
    <div id="upnpForwarder" class="standardContainer">
 | 
			
		||||
        <div id="upnpRouterNotFoundWarning" class="ui basic segment" style="display: none;">
 | 
			
		||||
            <h2>UPnP Port Forwarder</h2>
 | 
			
		||||
            <p>Port forward using UPnP protocol, only works with some of the supported gateway routers</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="ui basic segment">
 | 
			
		||||
            <div class="ui message">
 | 
			
		||||
                <div class="header"><i class="yellow exclamation triangle icon"></i> UPnP Gateway Not Found</div>
 | 
			
		||||
                <p>No UPnP router found in network. Please ensure your router supports UPnP and is enabled.</p>
 | 
			
		||||
                <button id="retryBtn" onclick="searchUpnpRouter();" class="ui basic small button"><i class="green refresh icon"></i> Search again</button>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="ui basic segment">
 | 
			
		||||
                <h3 class="ui header">UPnP Port Forwarder</h3>
 | 
			
		||||
                <div class="ui toggle checkbox">
 | 
			
		||||
                    <input type="checkbox" id="upnpToggle" onchange="toggleUpnpState();">
 | 
			
		||||
                    <label>Enable UPnP Forwarding</label>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="ui divider"></div>
 | 
			
		||||
            <table class="ui celled table">
 | 
			
		||||
                <thead>
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <th>Rule Name</th>
 | 
			
		||||
                        <th>Forwarded Port</th>
 | 
			
		||||
                        <th>Action</th>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                </thead>
 | 
			
		||||
                <tbody id="forwardList">
 | 
			
		||||
                    <tr>
 | 
			
		||||
                        <td>Example Rule</td>
 | 
			
		||||
                        <td>8080</td>
 | 
			
		||||
                        <td>
 | 
			
		||||
                            <button class="ui button">Edit</button>
 | 
			
		||||
                            <button class="ui button">Remove</button>
 | 
			
		||||
                        </td>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                </tbody>
 | 
			
		||||
            </table>
 | 
			
		||||
            <div class="ui divider"></div>
 | 
			
		||||
            <div>
 | 
			
		||||
                <h4>Port Forward Rules</h4>
 | 
			
		||||
                <form class="ui form" id="addRuleForm">
 | 
			
		||||
                    <div class="field">
 | 
			
		||||
                        <label>Rule Name</label>
 | 
			
		||||
                        <input type="text" name="ruleName" placeholder="Rule Name">
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="field">
 | 
			
		||||
                        <label>Port</label>
 | 
			
		||||
                        <input type="number" name="port" placeholder="Port" min="1" max="65535">
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <button onclick="handleAddForward(event);" class="ui small basic button"><i class="ui blue add icon"></i> Add Rule</button>
 | 
			
		||||
                </form>
 | 
			
		||||
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <script>
 | 
			
		||||
 | 
			
		||||
        function toggleUpnpState() {
 | 
			
		||||
            let isChecked = $("#upnpToggle").prop("checked");
 | 
			
		||||
            $.cjax({
 | 
			
		||||
                url: './api/toggle',
 | 
			
		||||
                method: "POST",
 | 
			
		||||
                data: {
 | 
			
		||||
                    enable: isChecked
 | 
			
		||||
                },
 | 
			
		||||
                success: function(data) {
 | 
			
		||||
                    if (data.error != undefined) {
 | 
			
		||||
                        // Error
 | 
			
		||||
                        parent.msgbox(data.error, false);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        // Success
 | 
			
		||||
                        parent.msgbox("UPnP Forwarding " + (isChecked ? "enabled" : "disabled"), true);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function initUPnPEnableState(){
 | 
			
		||||
            $.cjax({
 | 
			
		||||
                url: './api/enable',
 | 
			
		||||
                success: function(data) {
 | 
			
		||||
                    if (data == true){
 | 
			
		||||
                        //Upnp forwarding enabled
 | 
			
		||||
                        $("#upnpToggle").prop("checked", true);
 | 
			
		||||
                    }else{
 | 
			
		||||
                        //Upnp forwarding disabled
 | 
			
		||||
                        $("#upnpToggle").prop("checked", false);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        initUPnPEnableState();
 | 
			
		||||
 | 
			
		||||
        function searchUpnpRouter(){
 | 
			
		||||
            $("#retryBtn").addClass("loading");
 | 
			
		||||
            parent.msgbox("Searching for UPnP router (will take a few minutes)...", true);
 | 
			
		||||
            $.cjax({
 | 
			
		||||
                url: './api/usable',
 | 
			
		||||
                method: "POST",
 | 
			
		||||
                success: function(data) {
 | 
			
		||||
                    if (data.error != undefined){
 | 
			
		||||
                        //Not found
 | 
			
		||||
                        parent.msgbox("UPnP router not found", false);
 | 
			
		||||
                    }else{
 | 
			
		||||
                        //Found
 | 
			
		||||
                        parent.msgbox("UPnP router discovered", true);
 | 
			
		||||
                    }
 | 
			
		||||
                    initUpnpUsableState();
 | 
			
		||||
                    $("#retryBtn").removeClass("loading");
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        //Check if UPnP is usable
 | 
			
		||||
        function initUpnpUsableState(){
 | 
			
		||||
            $.cjax({
 | 
			
		||||
                url: './api/usable',
 | 
			
		||||
                success: function(data) {
 | 
			
		||||
                    if (data == true){
 | 
			
		||||
                        //Upnp router found in network, enable the page
 | 
			
		||||
                        $('#upnpRouterNotFoundWarning').hide();
 | 
			
		||||
                    }else{
 | 
			
		||||
                        //No upnp router found in network, disable the page
 | 
			
		||||
                        $('#upnpForwarder').addClass('disabled');
 | 
			
		||||
                        $('#upnpRouterNotFoundWarning').show();
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function handleAddForward(event){
 | 
			
		||||
            event.preventDefault();
 | 
			
		||||
            let ruleName = $("#addRuleForm input[name='ruleName']").val();
 | 
			
		||||
            let port = $("#addRuleForm input[name='port']").val();
 | 
			
		||||
            if (ruleName == "" || port == ""){
 | 
			
		||||
                parent.msgbox("Please fill in all fields", false);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            $.cjax({
 | 
			
		||||
                url: './api/forward',
 | 
			
		||||
                method: "POST",
 | 
			
		||||
                data: {
 | 
			
		||||
                    name: ruleName,
 | 
			
		||||
                    port: port
 | 
			
		||||
                },
 | 
			
		||||
                success: function(data) {
 | 
			
		||||
                    if (data.error != undefined){
 | 
			
		||||
                        //Error
 | 
			
		||||
                        parent.msgbox(data.error, false);
 | 
			
		||||
                    }else{
 | 
			
		||||
                        //Success
 | 
			
		||||
                        parent.msgbox("Rule added successfully", true);
 | 
			
		||||
                        initForwardList();
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    $("#addRuleForm input[name='ruleName']").val('');
 | 
			
		||||
                    $("#addRuleForm input[name='port']").val('');
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function handleRemoveForward(portNo){
 | 
			
		||||
            $.cjax({
 | 
			
		||||
                url: './api/remove',
 | 
			
		||||
                method: "POST",
 | 
			
		||||
                data: {
 | 
			
		||||
                    port: portNo
 | 
			
		||||
                },
 | 
			
		||||
                success: function(data) {
 | 
			
		||||
                    if (data.error != undefined){
 | 
			
		||||
                        //Error
 | 
			
		||||
                        parent.msgbox(data.error, false);
 | 
			
		||||
                    }else{
 | 
			
		||||
                        //Success
 | 
			
		||||
                        parent.msgbox("Rule removed successfully", true);
 | 
			
		||||
                        initForwardList();
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function editForwardRule(row){
 | 
			
		||||
            let ruleName = $(row).closest('tr').find('td:eq(0)').text();
 | 
			
		||||
            let portNumber = $(row).closest('tr').find('td:eq(1)').text();
 | 
			
		||||
            $(row).closest('tr').html(`
 | 
			
		||||
                <td>
 | 
			
		||||
                    <div class="ui fluid input">
 | 
			
		||||
                        <input type="text" value="${ruleName}" onkeypress="if(event.key === 'Enter') $(this).closest('tr').find('td:eq(1) input').focus();">
 | 
			
		||||
                    </div>
 | 
			
		||||
                </td>
 | 
			
		||||
                <td>
 | 
			
		||||
                    <div class="ui fluid input">
 | 
			
		||||
                        <input type="number" value="${portNumber}" class="ui input" min="1" max="65535" onkeypress="if(event.key === 'Enter') saveForwardRule(this, '${portNumber}');">
 | 
			
		||||
                    </div>
 | 
			
		||||
                </td>
 | 
			
		||||
                <td>
 | 
			
		||||
                    <button onclick="saveForwardRule(this, '${portNumber}');" class="ui basic small circular icon button"><i class="ui green save icon"></i></button>
 | 
			
		||||
                    <button onclick="cancelEditForwardRule(this, '${ruleName}', '${portNumber}');" class="ui basic small circular icon button"><i class="ui cancel icon"></i></button>
 | 
			
		||||
                </td>
 | 
			
		||||
            `);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function cancelEditForwardRule(){
 | 
			
		||||
            initForwardList();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        function saveForwardRule(row, portNo){
 | 
			
		||||
            let ruleName = $(row).closest('tr').find('td:eq(0) input').val();
 | 
			
		||||
            let newPortNo = $(row).closest('tr').find('td:eq(1) input').val();
 | 
			
		||||
            if (ruleName == "" || newPortNo == ""){
 | 
			
		||||
                parent.msgbox("Please fill in all fields", false);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            $.cjax({
 | 
			
		||||
                url: './api/edit',
 | 
			
		||||
                method: "POST",
 | 
			
		||||
                data: {
 | 
			
		||||
                    name: ruleName,
 | 
			
		||||
                    port: newPortNo,
 | 
			
		||||
                    oldPort: portNo
 | 
			
		||||
                },
 | 
			
		||||
                success: function(data) {
 | 
			
		||||
                    if (data.error != undefined){
 | 
			
		||||
                        //Error
 | 
			
		||||
                        parent.msgbox(data.error, false);
 | 
			
		||||
                    }else{
 | 
			
		||||
                        //Success
 | 
			
		||||
                        parent.msgbox("Rule updated successfully", true);
 | 
			
		||||
                        initForwardList();
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        //Load forward list
 | 
			
		||||
        function initForwardList(){
 | 
			
		||||
            $("#forwardList").html('<tr><td colspan="3"> <i class="ui loading spinner icon"></i> Loading...</tr>');
 | 
			
		||||
            $.cjax({
 | 
			
		||||
                url: './api/forward',
 | 
			
		||||
                success: function(data) {
 | 
			
		||||
                    if (data.error != undefined){
 | 
			
		||||
                        //Error
 | 
			
		||||
                        parent.msgbox(data.error, false);
 | 
			
		||||
                    }else{
 | 
			
		||||
                        //Success
 | 
			
		||||
                        $("#forwardList").empty();
 | 
			
		||||
                        let rows = '';
 | 
			
		||||
                        data.forEach(row => {
 | 
			
		||||
                            $("#forwardList").append(`
 | 
			
		||||
                                <tr>
 | 
			
		||||
                                    <td>${row.RuleName}</td>
 | 
			
		||||
                                    <td>${row.PortNumber}</td>
 | 
			
		||||
                                    <td>
 | 
			
		||||
                                        <button onclick="editForwardRule(this);" class="ui basic small circular icon button"><i class="ui edit icon"></i></button>
 | 
			
		||||
                                        <button onclick="handleRemoveForward(${row.PortNumber});" class="ui basic small red circular icon button"><i class="ui red trash icon"></i></button>
 | 
			
		||||
                                    </td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                            `);
 | 
			
		||||
                        });
 | 
			
		||||
 | 
			
		||||
                        if (data == null || data.length == 0){
 | 
			
		||||
                            //No rules
 | 
			
		||||
                            $("#forwardList").append(`
 | 
			
		||||
                                <tr>
 | 
			
		||||
                                    <td colspan="3"><i class="ui green check circle icon"></i> No running port forward rules</td>
 | 
			
		||||
                                </tr>`);
 | 
			
		||||
                        }
 | 
			
		||||
                    } 
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        initUpnpUsableState();
 | 
			
		||||
        initForwardList();
 | 
			
		||||
    </script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										145
									
								
								example/plugins/ztnc/mod/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								example/plugins/ztnc/mod/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,145 @@
 | 
			
		||||
package zoraxy_plugin
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type PluginUiDebugRouter struct {
 | 
			
		||||
	PluginID         string //The ID of the plugin
 | 
			
		||||
	TargetDir        string //The directory where the UI files are stored
 | 
			
		||||
	HandlerPrefix    string //The prefix of the handler used to route this router, e.g. /ui
 | 
			
		||||
	EnableDebug      bool   //Enable debug mode
 | 
			
		||||
	terminateHandler func() //The handler to be called when the plugin is terminated
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
 | 
			
		||||
// The targetDir is the directory where the UI files are stored (e.g. ./www)
 | 
			
		||||
// The handlerPrefix is the prefix of the handler used to route this router
 | 
			
		||||
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
 | 
			
		||||
// All prefix should not end with a slash
 | 
			
		||||
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
 | 
			
		||||
	//Make sure all prefix are in /prefix format
 | 
			
		||||
	if !strings.HasPrefix(handlerPrefix, "/") {
 | 
			
		||||
		handlerPrefix = "/" + handlerPrefix
 | 
			
		||||
	}
 | 
			
		||||
	handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
 | 
			
		||||
 | 
			
		||||
	//Return the PluginUiRouter
 | 
			
		||||
	return &PluginUiDebugRouter{
 | 
			
		||||
		PluginID:      pluginID,
 | 
			
		||||
		TargetDir:     targetDir,
 | 
			
		||||
		HandlerPrefix: handlerPrefix,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
 | 
			
		||||
	//Get the CSRF token from header
 | 
			
		||||
	csrfToken := r.Header.Get("X-Zoraxy-Csrf")
 | 
			
		||||
	if csrfToken == "" {
 | 
			
		||||
		csrfToken = "missing-csrf-token"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Return the middleware
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		// Check if the request is for an HTML file
 | 
			
		||||
		if strings.HasSuffix(r.URL.Path, ".html") {
 | 
			
		||||
			//Read the target file from file system
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			body := string(targetFileContent)
 | 
			
		||||
			body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
			w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			//Check if the request is for a directory
 | 
			
		||||
			//Check if the directory has an index.html file
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			if _, err := os.Stat(targetFilePath); err == nil {
 | 
			
		||||
				//Serve the index.html file
 | 
			
		||||
				targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
				body := string(targetFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
		fsHandler.ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetHttpHandler returns the http.Handler for the PluginUiRouter
 | 
			
		||||
func (p *PluginUiDebugRouter) Handler() http.Handler {
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		//Remove the plugin UI handler path prefix
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rewrittenURL := r.RequestURI
 | 
			
		||||
		rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
 | 
			
		||||
		rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
 | 
			
		||||
		r.URL.Path = rewrittenURL
 | 
			
		||||
		r.RequestURI = rewrittenURL
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Println(r.URL.Path)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Serve the file from the file system
 | 
			
		||||
		fsHandler := http.FileServer(http.Dir(p.TargetDir))
 | 
			
		||||
 | 
			
		||||
		// Replace {{csrf_token}} with the actual CSRF token and serve the file
 | 
			
		||||
		p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
 | 
			
		||||
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
 | 
			
		||||
// if mux is nil, the handler will be registered to http.DefaultServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
 | 
			
		||||
	p.terminateHandler = termFunc
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
	mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		p.terminateHandler()
 | 
			
		||||
		w.WriteHeader(http.StatusOK)
 | 
			
		||||
		go func() {
 | 
			
		||||
			//Make sure the response is sent before the plugin is terminated
 | 
			
		||||
			time.Sleep(100 * time.Millisecond)
 | 
			
		||||
			os.Exit(0)
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the file system UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
@@ -74,6 +74,20 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			// Check if the directory has an index.html file
 | 
			
		||||
			indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html"
 | 
			
		||||
			indexFilePath = p.TargetFsPrefix + "/" + indexFilePath
 | 
			
		||||
			indexFilePath = strings.TrimPrefix(indexFilePath, "/")
 | 
			
		||||
			indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				body := string(indexFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
@@ -130,3 +144,13 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the embed UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -102,7 +102,7 @@
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
        function initGANetID(){
 | 
			
		||||
            $.get("/api/gan/network/info", function(data){
 | 
			
		||||
            $.get("./api/gan/network/info", function(data){
 | 
			
		||||
                if (data.error !== undefined){
 | 
			
		||||
                    msgbox(data.error, false, 5000)
 | 
			
		||||
                }else{
 | 
			
		||||
 
 | 
			
		||||
@@ -29,6 +29,7 @@ func RegisterHTTPProxyAPIs(authRouter *auth.RouterDef) {
 | 
			
		||||
	authRouter.HandleFunc("/api/proxy/status", ReverseProxyStatus)
 | 
			
		||||
	authRouter.HandleFunc("/api/proxy/toggle", ReverseProxyToggleRuleSet)
 | 
			
		||||
	authRouter.HandleFunc("/api/proxy/list", ReverseProxyList)
 | 
			
		||||
	authRouter.HandleFunc("/api/proxy/listTags", ReverseProxyListTags)
 | 
			
		||||
	authRouter.HandleFunc("/api/proxy/detail", ReverseProxyListDetail)
 | 
			
		||||
	authRouter.HandleFunc("/api/proxy/edit", ReverseProxyHandleEditEndpoint)
 | 
			
		||||
	authRouter.HandleFunc("/api/proxy/setAlias", ReverseProxyHandleAlias)
 | 
			
		||||
@@ -225,6 +226,11 @@ func RegisterPluginAPIs(authRouter *auth.RouterDef) {
 | 
			
		||||
	authRouter.HandleFunc("/api/plugins/enable", pluginManager.HandleEnablePlugin)
 | 
			
		||||
	authRouter.HandleFunc("/api/plugins/disable", pluginManager.HandleDisablePlugin)
 | 
			
		||||
	authRouter.HandleFunc("/api/plugins/icon", pluginManager.HandleLoadPluginIcon)
 | 
			
		||||
 | 
			
		||||
	authRouter.HandleFunc("/api/plugins/groups/list", pluginManager.HandleListPluginGroups)
 | 
			
		||||
	authRouter.HandleFunc("/api/plugins/groups/add", pluginManager.HandleAddPluginToGroup)
 | 
			
		||||
	authRouter.HandleFunc("/api/plugins/groups/remove", pluginManager.HandleRemovePluginFromGroup)
 | 
			
		||||
	authRouter.HandleFunc("/api/plugins/groups/deleteTag", pluginManager.HandleRemovePluginGroup)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Register the APIs for Auth functions, due to scoping issue some functions are defined here
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										101
									
								
								src/mod/plugins/groups.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/mod/plugins/groups.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
package plugins
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// ListPluginGroups returns a map of plugin groups
 | 
			
		||||
func (m *Manager) ListPluginGroups() map[string][]string {
 | 
			
		||||
	pluginGroup := map[string][]string{}
 | 
			
		||||
	m.Options.pluginGroupsMutex.RLock()
 | 
			
		||||
	for k, v := range m.Options.PluginGroups {
 | 
			
		||||
		pluginGroup[k] = append([]string{}, v...)
 | 
			
		||||
	}
 | 
			
		||||
	m.Options.pluginGroupsMutex.RUnlock()
 | 
			
		||||
	return pluginGroup
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// AddPluginToGroup adds a plugin to a group
 | 
			
		||||
func (m *Manager) AddPluginToGroup(tag, pluginID string) error {
 | 
			
		||||
	//Check if the plugin exists
 | 
			
		||||
	plugin, ok := m.LoadedPlugins[pluginID]
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return errors.New("plugin not found")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Check if the plugin is a router type plugin
 | 
			
		||||
	if plugin.Spec.Type != zoraxy_plugin.PluginType_Router {
 | 
			
		||||
		return errors.New("plugin is not a router type plugin")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	m.Options.pluginGroupsMutex.Lock()
 | 
			
		||||
	//Check if the tag exists
 | 
			
		||||
	_, ok = m.Options.PluginGroups[tag]
 | 
			
		||||
	if !ok {
 | 
			
		||||
		m.Options.PluginGroups[tag] = []string{pluginID}
 | 
			
		||||
		m.Options.pluginGroupsMutex.Unlock()
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Add the plugin to the group
 | 
			
		||||
	m.Options.PluginGroups[tag] = append(m.Options.PluginGroups[tag], pluginID)
 | 
			
		||||
 | 
			
		||||
	m.Options.pluginGroupsMutex.Unlock()
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RemovePluginFromGroup removes a plugin from a group
 | 
			
		||||
func (m *Manager) RemovePluginFromGroup(tag, pluginID string) error {
 | 
			
		||||
	m.Options.pluginGroupsMutex.Lock()
 | 
			
		||||
	defer m.Options.pluginGroupsMutex.Unlock()
 | 
			
		||||
	//Check if the tag exists
 | 
			
		||||
	_, ok := m.Options.PluginGroups[tag]
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return errors.New("tag not found")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Remove the plugin from the group
 | 
			
		||||
	pluginList := m.Options.PluginGroups[tag]
 | 
			
		||||
	for i, id := range pluginList {
 | 
			
		||||
		if id == pluginID {
 | 
			
		||||
			pluginList = append(pluginList[:i], pluginList[i+1:]...)
 | 
			
		||||
			m.Options.PluginGroups[tag] = pluginList
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return errors.New("plugin not found")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RemovePluginGroup removes a plugin group
 | 
			
		||||
func (m *Manager) RemovePluginGroup(tag string) error {
 | 
			
		||||
	m.Options.pluginGroupsMutex.Lock()
 | 
			
		||||
	defer m.Options.pluginGroupsMutex.Unlock()
 | 
			
		||||
	_, ok := m.Options.PluginGroups[tag]
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return errors.New("tag not found")
 | 
			
		||||
	}
 | 
			
		||||
	delete(m.Options.PluginGroups, tag)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SavePluginGroupsFromFile loads plugin groups from a file
 | 
			
		||||
func (m *Manager) SavePluginGroupsToFile() error {
 | 
			
		||||
	m.Options.pluginGroupsMutex.RLock()
 | 
			
		||||
	pluginGroupsCopy := make(map[string][]string)
 | 
			
		||||
	for k, v := range m.Options.PluginGroups {
 | 
			
		||||
		pluginGroupsCopy[k] = append([]string{}, v...)
 | 
			
		||||
	}
 | 
			
		||||
	m.Options.pluginGroupsMutex.RUnlock()
 | 
			
		||||
 | 
			
		||||
	//Write to file
 | 
			
		||||
	js, _ := json.Marshal(pluginGroupsCopy)
 | 
			
		||||
	err := os.WriteFile(m.Options.PluginGroupsConfig, js, 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
@@ -11,6 +11,146 @@ import (
 | 
			
		||||
	"imuslab.com/zoraxy/mod/utils"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
/* Plugin Groups */
 | 
			
		||||
// HandleListPluginGroups handles the request to list all plugin groups
 | 
			
		||||
func (m *Manager) HandleListPluginGroups(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	targetTag, err := utils.GetPara(r, "tag")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		//List all tags
 | 
			
		||||
		pluginGroups := m.ListPluginGroups()
 | 
			
		||||
		js, _ := json.Marshal(pluginGroups)
 | 
			
		||||
		utils.SendJSONResponse(w, string(js))
 | 
			
		||||
	} else {
 | 
			
		||||
		//List the plugins under the tag
 | 
			
		||||
		m.tagPluginListMutex.RLock()
 | 
			
		||||
		plugins, ok := m.tagPluginList[targetTag]
 | 
			
		||||
		m.tagPluginListMutex.RUnlock()
 | 
			
		||||
		if !ok {
 | 
			
		||||
			//Return empty array
 | 
			
		||||
			js, _ := json.Marshal([]string{})
 | 
			
		||||
			utils.SendJSONResponse(w, string(js))
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Sort the plugin by its name
 | 
			
		||||
		sort.Slice(plugins, func(i, j int) bool {
 | 
			
		||||
			return plugins[i].Spec.Name < plugins[j].Spec.Name
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		js, err := json.Marshal(plugins)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			http.Error(w, err.Error(), http.StatusInternalServerError)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		utils.SendJSONResponse(w, string(js))
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// HandleAddPluginToGroup handles the request to add a plugin to a group
 | 
			
		||||
func (m *Manager) HandleAddPluginToGroup(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	tag, err := utils.PostPara(r, "tag")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, "tag not found")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	pluginID, err := utils.PostPara(r, "plugin_id")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, "plugin_id not found")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Check if plugin exists
 | 
			
		||||
	_, err = m.GetPluginByID(pluginID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Add the plugin to the group
 | 
			
		||||
	err = m.AddPluginToGroup(tag, pluginID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Save the plugin groups to file
 | 
			
		||||
	err = m.SavePluginGroupsToFile()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Update the radix tree mapping
 | 
			
		||||
	m.UpdateTagsToPluginMaps()
 | 
			
		||||
 | 
			
		||||
	utils.SendOK(w)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// HandleRemovePluginFromGroup handles the request to remove a plugin from a group
 | 
			
		||||
func (m *Manager) HandleRemovePluginFromGroup(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	tag, err := utils.PostPara(r, "tag")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, "tag not found")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	pluginID, err := utils.PostPara(r, "plugin_id")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, "plugin_id not found")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Remove the plugin from the group
 | 
			
		||||
	err = m.RemovePluginFromGroup(tag, pluginID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Save the plugin groups to file
 | 
			
		||||
	err = m.SavePluginGroupsToFile()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Update the radix tree mapping
 | 
			
		||||
	m.UpdateTagsToPluginMaps()
 | 
			
		||||
 | 
			
		||||
	utils.SendOK(w)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// HandleRemovePluginGroup handles the request to remove a plugin group
 | 
			
		||||
func (m *Manager) HandleRemovePluginGroup(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	tag, err := utils.PostPara(r, "tag")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, "tag not found")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Remove the plugin group
 | 
			
		||||
	err = m.RemovePluginGroup(tag)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Save the plugin groups to file
 | 
			
		||||
	err = m.SavePluginGroupsToFile()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		utils.SendErrorResponse(w, err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Update the radix tree mapping
 | 
			
		||||
	m.UpdateTagsToPluginMaps()
 | 
			
		||||
 | 
			
		||||
	utils.SendOK(w)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Plugin APIs */
 | 
			
		||||
// HandleListPlugins handles the request to list all loaded plugins
 | 
			
		||||
func (m *Manager) HandleListPlugins(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	plugins, err := m.ListLoadedPlugins()
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ package plugins
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
@@ -53,10 +52,16 @@ func (m *Manager) StartPlugin(pluginID string) error {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	stdErrPipe, err := cmd.StderrPipe()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := cmd.Start(); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Create a goroutine to handle the STDOUT of the plugin
 | 
			
		||||
	go func() {
 | 
			
		||||
		buf := make([]byte, 1)
 | 
			
		||||
		lineBuf := ""
 | 
			
		||||
@@ -82,6 +87,48 @@ func (m *Manager) StartPlugin(pluginID string) error {
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	//Create a goroutine to handle the STDERR of the plugin
 | 
			
		||||
	go func() {
 | 
			
		||||
		buf := make([]byte, 1)
 | 
			
		||||
		lineBuf := ""
 | 
			
		||||
		for {
 | 
			
		||||
			n, err := stdErrPipe.Read(buf)
 | 
			
		||||
			if n > 0 {
 | 
			
		||||
				lineBuf += string(buf[:n])
 | 
			
		||||
				for {
 | 
			
		||||
					if idx := strings.IndexByte(lineBuf, '\n'); idx != -1 {
 | 
			
		||||
						m.handlePluginSTDERR(pluginID, lineBuf[:idx])
 | 
			
		||||
						lineBuf = lineBuf[idx+1:]
 | 
			
		||||
					} else {
 | 
			
		||||
						break
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				if err != io.EOF {
 | 
			
		||||
					m.handlePluginSTDERR(pluginID, lineBuf) // handle any remaining data
 | 
			
		||||
				}
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	//Create a goroutine to wait for the plugin to exit
 | 
			
		||||
	go func() {
 | 
			
		||||
		err := cmd.Wait()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			//In theory this should not happen except for a crash
 | 
			
		||||
			m.Log("plugin "+thisPlugin.Spec.ID+" encounted a fatal error. Disabling plugin...", err)
 | 
			
		||||
 | 
			
		||||
			//Set the plugin state to disabled
 | 
			
		||||
			thisPlugin.Enabled = false
 | 
			
		||||
 | 
			
		||||
			//Generate a new static forwarder radix tree
 | 
			
		||||
			m.UpdateTagsToPluginMaps()
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	//Create a UI forwarder if the plugin has UI
 | 
			
		||||
	err = m.StartUIHandlerForPlugin(thisPlugin, pluginConfiguration.Port)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -119,8 +166,6 @@ func (m *Manager) StartUIHandlerForPlugin(targetPlugin *Plugin, pluginListeningP
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Println("DEBUG: Requesting Plugin UI URL: ", pluginUIURL)
 | 
			
		||||
 | 
			
		||||
	// Generate the plugin subpath to be trimmed
 | 
			
		||||
	pluginMatchingPath := filepath.ToSlash(filepath.Join("/plugin.ui/"+targetPlugin.Spec.ID+"/")) + "/"
 | 
			
		||||
	if targetPlugin.Spec.UIPath != "" {
 | 
			
		||||
@@ -148,6 +193,19 @@ func (m *Manager) handlePluginSTDOUT(pluginID string, line string) {
 | 
			
		||||
	m.Log("["+thisPlugin.Spec.Name+":"+strconv.Itoa(processID)+"] "+line, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *Manager) handlePluginSTDERR(pluginID string, line string) {
 | 
			
		||||
	thisPlugin, err := m.GetPluginByID(pluginID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	processID := -1
 | 
			
		||||
	if thisPlugin.process != nil && thisPlugin.process.Process != nil {
 | 
			
		||||
		// Get the process ID of the plugin
 | 
			
		||||
		processID = thisPlugin.process.Process.Pid
 | 
			
		||||
	}
 | 
			
		||||
	m.Log("["+thisPlugin.Spec.Name+":"+strconv.Itoa(processID)+"] "+line, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// StopPlugin stops a plugin, it is garanteed that the plugin is stopped after this function
 | 
			
		||||
func (m *Manager) StopPlugin(pluginID string) error {
 | 
			
		||||
	thisPlugin, err := m.GetPluginByID(pluginID)
 | 
			
		||||
 
 | 
			
		||||
@@ -82,6 +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))
 | 
			
		||||
			if m.GetPluginPreviousEnableState(thisPlugin.Spec.ID) {
 | 
			
		||||
				err = m.StartPlugin(thisPlugin.Spec.ID)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
@@ -118,11 +119,11 @@ func (m *Manager) GetPluginByID(pluginID string) (*Plugin, error) {
 | 
			
		||||
 | 
			
		||||
// EnablePlugin enables a plugin
 | 
			
		||||
func (m *Manager) EnablePlugin(pluginID string) error {
 | 
			
		||||
	m.Options.Database.Write("plugins", pluginID, true)
 | 
			
		||||
	err := m.StartPlugin(pluginID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	m.Options.Database.Write("plugins", pluginID, true)
 | 
			
		||||
	//Generate the static forwarder radix tree
 | 
			
		||||
	m.UpdateTagsToPluginMaps()
 | 
			
		||||
	return nil
 | 
			
		||||
 
 | 
			
		||||
@@ -13,8 +13,6 @@ func (m *Manager) HandleRoute(w http.ResponseWriter, r *http.Request, tags []str
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return false
 | 
			
		||||
 | 
			
		||||
	//For each tag, check if the request path matches the static capture path                    //Wait group for the goroutines
 | 
			
		||||
	var staticRoutehandlers []*Plugin          //The handler for the request, can be multiple plugins
 | 
			
		||||
	var longestPrefixAcrossAlltags string = "" //The longest prefix across all tags
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										145
									
								
								src/mod/plugins/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								src/mod/plugins/zoraxy_plugin/dev_webserver.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,145 @@
 | 
			
		||||
package zoraxy_plugin
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type PluginUiDebugRouter struct {
 | 
			
		||||
	PluginID         string //The ID of the plugin
 | 
			
		||||
	TargetDir        string //The directory where the UI files are stored
 | 
			
		||||
	HandlerPrefix    string //The prefix of the handler used to route this router, e.g. /ui
 | 
			
		||||
	EnableDebug      bool   //Enable debug mode
 | 
			
		||||
	terminateHandler func() //The handler to be called when the plugin is terminated
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
 | 
			
		||||
// The targetDir is the directory where the UI files are stored (e.g. ./www)
 | 
			
		||||
// The handlerPrefix is the prefix of the handler used to route this router
 | 
			
		||||
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
 | 
			
		||||
// All prefix should not end with a slash
 | 
			
		||||
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
 | 
			
		||||
	//Make sure all prefix are in /prefix format
 | 
			
		||||
	if !strings.HasPrefix(handlerPrefix, "/") {
 | 
			
		||||
		handlerPrefix = "/" + handlerPrefix
 | 
			
		||||
	}
 | 
			
		||||
	handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
 | 
			
		||||
 | 
			
		||||
	//Return the PluginUiRouter
 | 
			
		||||
	return &PluginUiDebugRouter{
 | 
			
		||||
		PluginID:      pluginID,
 | 
			
		||||
		TargetDir:     targetDir,
 | 
			
		||||
		HandlerPrefix: handlerPrefix,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
 | 
			
		||||
	//Get the CSRF token from header
 | 
			
		||||
	csrfToken := r.Header.Get("X-Zoraxy-Csrf")
 | 
			
		||||
	if csrfToken == "" {
 | 
			
		||||
		csrfToken = "missing-csrf-token"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//Return the middleware
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		// Check if the request is for an HTML file
 | 
			
		||||
		if strings.HasSuffix(r.URL.Path, ".html") {
 | 
			
		||||
			//Read the target file from file system
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			body := string(targetFileContent)
 | 
			
		||||
			body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
			w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			//Check if the request is for a directory
 | 
			
		||||
			//Check if the directory has an index.html file
 | 
			
		||||
			targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
 | 
			
		||||
			targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
 | 
			
		||||
			targetFilePath = strings.TrimPrefix(targetFilePath, "/")
 | 
			
		||||
			if _, err := os.Stat(targetFilePath); err == nil {
 | 
			
		||||
				//Serve the index.html file
 | 
			
		||||
				targetFileContent, err := os.ReadFile(targetFilePath)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					http.Error(w, "File not found", http.StatusNotFound)
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
				body := string(targetFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
		fsHandler.ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetHttpHandler returns the http.Handler for the PluginUiRouter
 | 
			
		||||
func (p *PluginUiDebugRouter) Handler() http.Handler {
 | 
			
		||||
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		//Remove the plugin UI handler path prefix
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rewrittenURL := r.RequestURI
 | 
			
		||||
		rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
 | 
			
		||||
		rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
 | 
			
		||||
		r.URL.Path = rewrittenURL
 | 
			
		||||
		r.RequestURI = rewrittenURL
 | 
			
		||||
		if p.EnableDebug {
 | 
			
		||||
			fmt.Println(r.URL.Path)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Serve the file from the file system
 | 
			
		||||
		fsHandler := http.FileServer(http.Dir(p.TargetDir))
 | 
			
		||||
 | 
			
		||||
		// Replace {{csrf_token}} with the actual CSRF token and serve the file
 | 
			
		||||
		p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
 | 
			
		||||
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
 | 
			
		||||
// if mux is nil, the handler will be registered to http.DefaultServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
 | 
			
		||||
	p.terminateHandler = termFunc
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
	mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		p.terminateHandler()
 | 
			
		||||
		w.WriteHeader(http.StatusOK)
 | 
			
		||||
		go func() {
 | 
			
		||||
			//Make sure the response is sent before the plugin is terminated
 | 
			
		||||
			time.Sleep(100 * time.Millisecond)
 | 
			
		||||
			os.Exit(0)
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the file system UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
@@ -74,6 +74,20 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl
 | 
			
		||||
			w.WriteHeader(http.StatusOK)
 | 
			
		||||
			w.Write([]byte(body))
 | 
			
		||||
			return
 | 
			
		||||
		} else if strings.HasSuffix(r.URL.Path, "/") {
 | 
			
		||||
			// Check if the directory has an index.html file
 | 
			
		||||
			indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html"
 | 
			
		||||
			indexFilePath = p.TargetFsPrefix + "/" + indexFilePath
 | 
			
		||||
			indexFilePath = strings.TrimPrefix(indexFilePath, "/")
 | 
			
		||||
			indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				body := string(indexFileContent)
 | 
			
		||||
				body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
 | 
			
		||||
				w.Header().Set("Content-Type", "text/html")
 | 
			
		||||
				w.WriteHeader(http.StatusOK)
 | 
			
		||||
				w.Write([]byte(body))
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//Call the next handler
 | 
			
		||||
@@ -130,3 +144,13 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser
 | 
			
		||||
		}()
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Attach the embed UI handler to the target http.ServeMux
 | 
			
		||||
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
 | 
			
		||||
	if mux == nil {
 | 
			
		||||
		mux = http.DefaultServeMux
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
 | 
			
		||||
	mux.Handle(p.HandlerPrefix+"/", p.Handler())
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1005,6 +1005,23 @@ func ReverseProxyListDetail(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// List all tags used in the proxy rules
 | 
			
		||||
func ReverseProxyListTags(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	results := []string{}
 | 
			
		||||
	dynamicProxyRouter.ProxyEndpoints.Range(func(key, value interface{}) bool {
 | 
			
		||||
		thisEndpoint := value.(*dynamicproxy.ProxyEndpoint)
 | 
			
		||||
		for _, tag := range thisEndpoint.Tags {
 | 
			
		||||
			if !utils.StringInArray(results, tag) {
 | 
			
		||||
				results = append(results, tag)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return true
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	js, _ := json.Marshal(results)
 | 
			
		||||
	utils.SendJSONResponse(w, string(js))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func ReverseProxyList(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	eptype, err := utils.PostPara(r, "type") //Support root and host
 | 
			
		||||
	if err != nil {
 | 
			
		||||
 
 | 
			
		||||
@@ -45,6 +45,9 @@
 | 
			
		||||
    function resizeIframe() {
 | 
			
		||||
        let iframe = document.getElementById('pluginContextLoader');
 | 
			
		||||
        let mainMenuHeight = document.getElementById('mainmenu').offsetHeight;
 | 
			
		||||
        if (mainMenuHeight == 0){
 | 
			
		||||
            mainMenuHeight = window.innerHeight - 198; //Fallback to window height
 | 
			
		||||
        }
 | 
			
		||||
        iframe.style.height = mainMenuHeight + 'px';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -57,6 +60,4 @@
 | 
			
		||||
        //On switch over to this page, load info
 | 
			
		||||
        resizeIframe();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    initPluginUIView();
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,3 +1,69 @@
 | 
			
		||||
<style>
 | 
			
		||||
  #selectablePluginList{
 | 
			
		||||
    max-height: 300px;
 | 
			
		||||
    overflow-y: auto;
 | 
			
		||||
    border-radius: 0.4em;
 | 
			
		||||
  }
 | 
			
		||||
  #selectablePluginList .item {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    padding: 1em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #selectablePluginList .item:hover {
 | 
			
		||||
    background-color: var(--theme_bg_active);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #selectedTagPluginList{
 | 
			
		||||
    max-height: 300px;
 | 
			
		||||
    overflow-y: auto;
 | 
			
		||||
    border-radius: 0.4em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #selectedTagPluginList .item {
 | 
			
		||||
    padding: 1em;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #selectedTagPluginList .item:hover {
 | 
			
		||||
    background-color: var(--theme_bg_active);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .selectablePluginItem{
 | 
			
		||||
    position: relative;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .selectablePluginItem.active{
 | 
			
		||||
    background-color: var(--theme_bg_active);
 | 
			
		||||
  }
 | 
			
		||||
    
 | 
			
		||||
  .selectablePluginItem .selectedIcon{
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    right: 0.2em;
 | 
			
		||||
    bottom: 0.2em;
 | 
			
		||||
    display:none;
 | 
			
		||||
  }
 | 
			
		||||
  .selectablePluginItem.active .selectedIcon{
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .selectedPluginItem{
 | 
			
		||||
    position: relative;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .selectedPluginItem.active{
 | 
			
		||||
    background-color: var(--theme_bg_active);
 | 
			
		||||
  }
 | 
			
		||||
    
 | 
			
		||||
  .selectedPluginItem .selectedIcon{
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    right: 0.2em;
 | 
			
		||||
    bottom: 0.2em;
 | 
			
		||||
    display:none;
 | 
			
		||||
  }
 | 
			
		||||
  .selectedPluginItem.active .selectedIcon{
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
<div class="standardContainer">
 | 
			
		||||
    <div class="ui basic segment">
 | 
			
		||||
        <h2>Plugins</h2>
 | 
			
		||||
@@ -7,6 +73,55 @@
 | 
			
		||||
      <div class="header">Experimental Feature</div>
 | 
			
		||||
      <p>This feature is experimental and may not work as expected. Use with caution.</p>
 | 
			
		||||
    </div>
 | 
			
		||||
    <h4 class="ui header">
 | 
			
		||||
      Plugin Map
 | 
			
		||||
     <div class="sub header">Assigning a plugin to a tag will make the plugin available to the HTTP Proxy rule with the same tag.</div>
 | 
			
		||||
    </h4>
 | 
			
		||||
    <div class="ui stackable grid">
 | 
			
		||||
      <div class="seven wide column">
 | 
			
		||||
        <!-- Selectable plugin list -->
 | 
			
		||||
        <div id="selectablePluginList" class="ui relaxed divided list" style="border: 1px solid var(--divider_color);">
 | 
			
		||||
          <div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
 | 
			
		||||
            <i class="ui arrow up icon"></i> Select a tag to view available plugins
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="two wide column" style="display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
        <!-- Add and Remove button -->
 | 
			
		||||
        <div>
 | 
			
		||||
          <button id="removeSelectedPluginFromTagBtn" class="ui basic red icon button" title="Remove selected plugin from tag">
 | 
			
		||||
            <i class="left arrow icon"></i>
 | 
			
		||||
          </button>
 | 
			
		||||
          <br>
 | 
			
		||||
          <button id="addSelectedPluginTotagBtn" class="ui basic green icon button" title="Add selected plugin to tag" style="margin-top: 0.4em;">
 | 
			
		||||
            <i class="right arrow icon"></i>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="seven wide column">
 | 
			
		||||
        <!-- Tag / Plugin List -->
 | 
			
		||||
        <div class="ui fluid selection dropdown" id="pluginTagList">
 | 
			
		||||
          <input type="hidden" name="tag">
 | 
			
		||||
          <i class="dropdown icon"></i>
 | 
			
		||||
          <div class="default text">Select Tag</div>
 | 
			
		||||
          <div class="menu">
 | 
			
		||||
            <!-- <div class="item" data-value="tag1">Tag 1</div> -->
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <button class="ui basic fluid button" onclick="loadTags();" style="margin-top: 0.4em;"><i class="ui green refresh icon"></i> Refresh Tag List</button>
 | 
			
		||||
        <div class="ui divider"></div>
 | 
			
		||||
        <div id="selectedTagPluginList" class="ui relaxed divided list" style="border: 1px solid var(--divider_color);">
 | 
			
		||||
            <div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
 | 
			
		||||
            <i class="ui arrow up icon"></i> Select a tag to view assigned plugins
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="ui divider"></div>
 | 
			
		||||
    <h4 class="ui header">
 | 
			
		||||
       Plugin List
 | 
			
		||||
      <div class="sub header">A list of installed plugins and their enable state</div>
 | 
			
		||||
    </h4>
 | 
			
		||||
    <table class="ui basic celled table">
 | 
			
		||||
        <thead>
 | 
			
		||||
          <tr>
 | 
			
		||||
@@ -22,11 +137,220 @@
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
var plugin_list = [];
 | 
			
		||||
 | 
			
		||||
/* Plugin Tag Assignment */
 | 
			
		||||
$('#pluginTagList').dropdown();
 | 
			
		||||
$('#pluginTagList').on('change', function() {
 | 
			
		||||
  const selectedTag = $(this).dropdown('get value');
 | 
			
		||||
  loadPluginsForTag(selectedTag);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function loadPluginsForTag(tag) {
 | 
			
		||||
  $.get(`/api/plugins/groups/list?tag=${tag}`, function(data) {
 | 
			
		||||
    $("#selectedTagPluginList").html("");
 | 
			
		||||
 | 
			
		||||
    let selectedPluginIDs = [];
 | 
			
		||||
    data.forEach(plugin => {
 | 
			
		||||
      $("#selectedTagPluginList").append(`
 | 
			
		||||
        <div class="item selectedPluginItem" pluginid="${plugin.Spec.id}">
 | 
			
		||||
          <img class="ui avatar image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}">
 | 
			
		||||
          <div class="content">
 | 
			
		||||
            <a class="header">${plugin.Spec.name}</a>
 | 
			
		||||
            <div class="description">${plugin.Spec.description}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="selectedIcon">
 | 
			
		||||
            <i class="ui large green circle check icon"></i>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      `);
 | 
			
		||||
 | 
			
		||||
      selectedPluginIDs.push(plugin.Spec.id);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (data.length == 0){
 | 
			
		||||
      $("#selectedTagPluginList").append(`
 | 
			
		||||
        <div class="item"  style="pointer-events: none; user-select: none; opacity: 0.6;">
 | 
			
		||||
          <i class="ui green circle check icon"></i> No plugins assigned to this tag
 | 
			
		||||
        </div>
 | 
			
		||||
      `);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    //Load the remaining plugins to the selectable list
 | 
			
		||||
    $("#selectablePluginList").html("");
 | 
			
		||||
    let selectablePluginCount = 0;
 | 
			
		||||
    plugin_list.forEach(plugin => {
 | 
			
		||||
      if (plugin.Spec.type != 0) {
 | 
			
		||||
        //This is not a router plugin, skip
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      if (!selectedPluginIDs.includes(plugin.Spec.id)) {
 | 
			
		||||
        $("#selectablePluginList").append(`
 | 
			
		||||
          <div class="item selectablePluginItem" pluginid="${plugin.Spec.id}">
 | 
			
		||||
            <img class="ui avatar image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}">
 | 
			
		||||
            <div class="content">
 | 
			
		||||
              <a class="header">${plugin.Spec.name}</a>
 | 
			
		||||
              <div class="description">${plugin.Spec.description}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="selectedIcon">
 | 
			
		||||
              <i class="ui large green circle check icon"></i>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        `);
 | 
			
		||||
        selectablePluginCount++;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (selectablePluginCount == 0){
 | 
			
		||||
      $("#selectablePluginList").append(`
 | 
			
		||||
        <div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
 | 
			
		||||
          <i class="ui green circle check icon"></i> No plugins available to assign
 | 
			
		||||
        </div>
 | 
			
		||||
      `);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    bindEventsToSelectableItems();
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//Load all the tags from the server
 | 
			
		||||
function loadTags(){
 | 
			
		||||
  $.get(`/api/proxy/listTags`, function(data){
 | 
			
		||||
    $("#pluginTagList").find(".menu").html("");
 | 
			
		||||
    if (data.error != undefined){
 | 
			
		||||
      msgbox(data.error, false);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    $("#pluginTagList").find(".menu").html("");
 | 
			
		||||
    data.forEach(tag => {
 | 
			
		||||
      $("#pluginTagList").find(".menu").append(`
 | 
			
		||||
        <div class="item" data-value="${tag}">${tag}</div>
 | 
			
		||||
      `);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
loadTags();
 | 
			
		||||
 | 
			
		||||
//This is used as a dummy function to initialize the selectable plugin list
 | 
			
		||||
function initSelectablePluginList(){
 | 
			
		||||
  $("#selectablePluginList").html("");
 | 
			
		||||
  $.get(`/api/plugins/list`, function(data){
 | 
			
		||||
      data.forEach(plugin => {
 | 
			
		||||
      if (plugin.Spec.type != 0) {
 | 
			
		||||
        //This is not a router plugin, skip
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      $("#selectablePluginList").append(`
 | 
			
		||||
        <div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
 | 
			
		||||
          <img class="ui avatar image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}">
 | 
			
		||||
          <div class="content">
 | 
			
		||||
            <a class="header">${plugin.Spec.name}</a>
 | 
			
		||||
            <div class="description">${plugin.Spec.description}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      `);
 | 
			
		||||
      
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (data.length == 0){
 | 
			
		||||
      $("#selectablePluginList").append(`
 | 
			
		||||
        <div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
 | 
			
		||||
          <p><i class="ui green circle check icon"></i> No plugins available to assign</p>
 | 
			
		||||
          <p>Plugins can be installed to Zoraxy by placing the plugin files in the <code>./plugins/{plugin_name}/</code> directory.</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      `);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
}
 | 
			
		||||
initSelectablePluginList();
 | 
			
		||||
 | 
			
		||||
function bindEventsToSelectableItems(){
 | 
			
		||||
  $(".selectablePluginItem").on("click", function(){
 | 
			
		||||
    $(".selectablePluginItem.active").removeClass("active");
 | 
			
		||||
    $(this).addClass("active");
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  $(".selectedPluginItem").on("click", function(){
 | 
			
		||||
      $(".selectedPluginItem.active").removeClass("active");
 | 
			
		||||
      $(this).addClass("active");
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//Bind events for the buttons
 | 
			
		||||
function bindTagAssignButtonEvents(){
 | 
			
		||||
  $("#addSelectedPluginTotagBtn").on("click", function(){
 | 
			
		||||
    const selectedPlugin = $(".selectablePluginItem.active");
 | 
			
		||||
    const selectedTag = $("#pluginTagList").dropdown("get value");
 | 
			
		||||
    if (selectedPlugin.length == 0){
 | 
			
		||||
      msgbox("Please select a plugin to add", false);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (selectedTag == ""){
 | 
			
		||||
      msgbox("Please select a tag to add the plugin to", false);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const pluginId = selectedPlugin.attr("pluginid");
 | 
			
		||||
    addPluginToTag(pluginId, selectedTag);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  $("#removeSelectedPluginFromTagBtn").on("click", function(){
 | 
			
		||||
    const selectedPlugin = $(".selectedPluginItem.active");
 | 
			
		||||
    const selectedTag = $("#pluginTagList").dropdown("get value");
 | 
			
		||||
    if (selectedPlugin.length == 0){
 | 
			
		||||
      msgbox("Please select a plugin to remove", false);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (selectedTag == ""){
 | 
			
		||||
      msgbox("Please select a tag to remove the plugin from", false);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const pluginId = selectedPlugin.attr("pluginid");
 | 
			
		||||
    removePluginFromTag(pluginId, selectedTag);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
bindTagAssignButtonEvents();
 | 
			
		||||
 | 
			
		||||
function addPluginToTag(pluginId, tag){
 | 
			
		||||
  $.cjax({
 | 
			
		||||
    url: '/api/plugins/groups/add',
 | 
			
		||||
    type: 'POST',
 | 
			
		||||
    data: {plugin_id: pluginId, tag: tag},
 | 
			
		||||
    success: function(data){
 | 
			
		||||
      if (data.error != undefined){
 | 
			
		||||
        msgbox(data.error, false);
 | 
			
		||||
      }else{
 | 
			
		||||
        msgbox("Plugin added to tag", true);
 | 
			
		||||
      }
 | 
			
		||||
      loadPluginsForTag(tag);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function removePluginFromTag(pluginId, tag){
 | 
			
		||||
  $.cjax({
 | 
			
		||||
    url: '/api/plugins/groups/remove',
 | 
			
		||||
    type: 'POST',
 | 
			
		||||
    data: {plugin_id: pluginId, tag: tag},
 | 
			
		||||
    success: function(data){
 | 
			
		||||
      if (data.error != undefined){
 | 
			
		||||
        msgbox(data.error, false);
 | 
			
		||||
      }else{
 | 
			
		||||
        msgbox("Plugin removed from tag", true);
 | 
			
		||||
      }
 | 
			
		||||
      loadPluginsForTag(tag);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Plugin List */
 | 
			
		||||
//Render the plugin list to Zoraxy homepage side menu
 | 
			
		||||
function initPluginSideMenu(){
 | 
			
		||||
  $.get(`/api/plugins/list`, function(data){
 | 
			
		||||
    $("#pluginMenu").html("");
 | 
			
		||||
    let enabledPluginCount = 0;
 | 
			
		||||
    plugin_list = data;
 | 
			
		||||
    data.forEach(plugin => {
 | 
			
		||||
      if (!plugin.Enabled){
 | 
			
		||||
        return;
 | 
			
		||||
@@ -55,6 +379,20 @@ function initPluginSideMenu(){
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    /* Handling Plugin Manager State, see index.html */
 | 
			
		||||
    //Callback to be called when the plugin list is updated
 | 
			
		||||
    if (plugin_manager_state && !plugin_manager_state.initated){
 | 
			
		||||
      plugin_manager_state.initated = true;
 | 
			
		||||
      if (plugin_manager_state.initCallback){
 | 
			
		||||
        plugin_manager_state.initCallback();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    //Callback to be called when the plugin list is updated
 | 
			
		||||
    if (plugin_manager_state && plugin_manager_state.listUpdateCallback){
 | 
			
		||||
      plugin_manager_state.listUpdateCallback();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
initPluginSideMenu();
 | 
			
		||||
@@ -119,6 +457,12 @@ function initiatePluginList(){
 | 
			
		||||
 | 
			
		||||
initiatePluginList();
 | 
			
		||||
 | 
			
		||||
/* Tag Assignment */
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* Plugin Lifecycle */
 | 
			
		||||
function startPlugin(pluginId, btn=undefined){
 | 
			
		||||
  if (btn) {
 | 
			
		||||
    $(btn).html('<i class="spinner loading icon"></i> Starting');
 | 
			
		||||
 
 | 
			
		||||
@@ -209,7 +209,20 @@
 | 
			
		||||
 | 
			
		||||
        <br><br>
 | 
			
		||||
        <script>
 | 
			
		||||
            $(".year").text(new Date().getFullYear());
 | 
			
		||||
           /* 
 | 
			
		||||
                Plugin Manager State 
 | 
			
		||||
           
 | 
			
		||||
                As some initiation must be done before the plugin manager
 | 
			
		||||
                loaded up the plugin list, this state here tells the plugin
 | 
			
		||||
                manager to do some actions after the plugin list is initiated
 | 
			
		||||
           */
 | 
			
		||||
            var plugin_manager_state = {
 | 
			
		||||
                initated: false, //Whether the plugin manager has been initiated
 | 
			
		||||
                initCallback: undefined, //Callback to be called when the plugin manager is initiated
 | 
			
		||||
                listUpdateCallback:  undefined //Callback to be called when the plugin list is updated
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            /*
 | 
			
		||||
                Loader function
 | 
			
		||||
 | 
			
		||||
@@ -258,13 +271,22 @@
 | 
			
		||||
                        try {
 | 
			
		||||
                            let parsedData = JSON.parse(tabID);
 | 
			
		||||
                            tabID = parsedData.tabID;
 | 
			
		||||
                           
 | 
			
		||||
                            
 | 
			
		||||
                            //Open the plugin context window
 | 
			
		||||
                            if (tabID == "pluginContextWindow"){
 | 
			
		||||
                                let pluginID = parsedData.pluginID;
 | 
			
		||||
                                let button = $("#pluginMenu").find(`[pluginid="${pluginID}"]`);
 | 
			
		||||
                                openTabById(tabID, button);
 | 
			
		||||
                                loadPluginUIContextIfAvailable();
 | 
			
		||||
                                if (pluginID == undefined){
 | 
			
		||||
                                    //Do not swap page
 | 
			
		||||
                                    return;
 | 
			
		||||
                                }
 | 
			
		||||
                                if (!openPluginTabByID(pluginID)){
 | 
			
		||||
                                    //Let the plugin manager to load the plugin list first
 | 
			
		||||
                                    plugin_manager_state.initCallback = function(){
 | 
			
		||||
                                        let pid = pluginID;
 | 
			
		||||
                                        openPluginTabByID(pid);
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                               
 | 
			
		||||
                            }
 | 
			
		||||
                        } catch (e) {
 | 
			
		||||
                            console.error("Invalid JSON data:", e);
 | 
			
		||||
@@ -290,6 +312,17 @@
 | 
			
		||||
                $('table').tablesort();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            //Function to open a plugin tab by its plugin id
 | 
			
		||||
            function openPluginTabByID(pluginID, tabID="pluginContextWindow"){
 | 
			
		||||
                let button = $("#pluginMenu").find(`[pluginid="${pluginID}"]`);
 | 
			
		||||
                if (button.length == 0){
 | 
			
		||||
                    return false;
 | 
			
		||||
                }
 | 
			
		||||
                openTabById(tabID, button);
 | 
			
		||||
                loadPluginUIContextIfAvailable();
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            function logout() {
 | 
			
		||||
                $.get("/api/auth/logout", function(response) {
 | 
			
		||||
                    if (response === "OK") {
 | 
			
		||||
@@ -495,6 +528,9 @@
 | 
			
		||||
                });
 | 
			
		||||
                $("body").css("overflow", "auto");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            /* Utilities */
 | 
			
		||||
            $(".year").text(new Date().getFullYear());
 | 
			
		||||
        </script>
 | 
			
		||||
    </body>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user