Added restful-api plugin

- Added restful-api example plugin
- Added more plugin docs
- Added zoraxy_plugin HandleFunc API
This commit is contained in:
Toby Chui 2025-05-30 15:57:59 +08:00
parent 29daa4402d
commit ddb1a8773e
34 changed files with 2376 additions and 5 deletions

1
.gitignore vendored
View File

@ -51,3 +51,4 @@ example/plugins/ztnc/ztnc.db
example/plugins/ztnc/authtoken.secret
example/plugins/ztnc/ztnc.db.lock
docs/plugins/docs.exe
*.exe

View File

@ -6,7 +6,7 @@ Last Update: 25/05/2025
A plugin can optionally expose a Web UI interface for user configuration.
**It is generally recommended that a plugin have such UI exposed for easy configurations.** As plugin installed via plugin store provides limited ways for a user to configure the plugin, the plugin web UI will be the best way for user to setup your plugin.
**A plugin must provide a UI, as it is part of the control mechanism of the plugin life cycle. (i.e. Zoraxy use the plugin UI HTTP server to communicate with the plugin for control signals)** As plugin installed via plugin store provides limited ways for a user to configure the plugin, the plugin web UI will be the best way for user to setup your plugin.
## Plugin Web UI Access

View File

@ -121,6 +121,8 @@ And here is an example `index.html` file that uses the Zoraxy internal resources
</html>
```
---
## 6. Creating a handler for Introspect
@ -240,6 +242,8 @@ After saving the `main.go` file, you can now build your plugin with `go build`.
After you are done, restart Zoraxy and enable your plugin in the Plugin List. Now you can test and debug your plugin with your HTTP Proxy Rules. All the STDOUT and STDERR of your plugin will be forwarded to the STDOUT of Zoraxy as well as the log file.
![image-20250530134148399](img/1. Hello World/image-20250530134148399.png)
**Tips**

View File

@ -0,0 +1,493 @@
# RESTful API Call in Web UI
Last Update: 29/05/2025
---
When developing a UI for your plugin, sometime you might need to make RESTFUL API calls to your plugin backend for setting up something or getting latest information from your plugin. In this example, I will show you how to create a plugin with RESTful api call capabilities with the embedded web server and the custom `cjax` function.
**Notes: This example assumes you have basic understanding on how to use jQuery `ajax` request.**
Lets get started!
---
## 1. Create the plugin folder structures
This step is identical to the Hello World example, where you create a plugin folder with the required go project structure in the folder. Please refer to the Hello World example section 1 to 5 for details.
---
## 2. Create Introspect
This is quite similar to the Hello World example as well, but we are changing some of the IDs to match what we want to do in this plugin.
```go
runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{
ID: "com.example.restful-example",
Name: "Restful Example",
Author: "foobar",
AuthorContact: "admin@example.com",
Description: "A simple demo for making RESTful API calls in plugin",
URL: "https://example.com",
Type: plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
// As this is a utility plugin, we don't need to capture any traffic
// but only serve the UI, so we set the UI (relative to the plugin path) to "/"
UIPath: UI_PATH,
})
if err != nil {
//Terminate or enter standalone mode here
panic(err)
}
```
---
## 3. Create an embedded web server with handlers
In this step, we create a basic embedded web file handler similar to the Hello World example, however, we will need to add a `http.HandleFunc` to the plugin so our front-end can request and communicate with the backend.
```go
embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH)
embedWebRouter.RegisterTerminateHandler(func() {
fmt.Println("Restful-example Exited")
}, nil)
//Register a simple API endpoint that will echo the request body
// Since we are using the default http.ServeMux, we can register the handler directly with the last
// parameter as nil
embedWebRouter.HandleFunc("/api/echo", func(w http.ResponseWriter, r *http.Request) {
//Some handler code here
}, nil)
```
The `embedWebRouter.HandleFunc` last parameter is the `http.Mux`, where if you have multiple web server listening interface, you can fill in different Mux based on your implementation. On of the examples is that, when you are developing a static web server plugin, where you need a dedicated HTTP listening endpoint that is not the one Zoraxy assigned to your plugin, you need to create two http.Mux and assign one of them for Zoraxy plugin UI purpose.
---
## 4. Modify the front-end HTML file to make request to backend
To make a RESTFUL API to your plugin, **you must use relative path in your request URL**.
Absolute path that start with `/` is only use for accessing Zoraxy resouces. For example, when you access `/img/logo.svg`, Zoraxy webmin HTTP router will return the logo of Zoraxy for you instead of `/plugins/your_plugin_name/{your_web_root}/img/logo.svg`.
### Making GET request
Making GET request is similar to what you would do in ordinary web development, but only limited to relative paths like `./api/foo/bar` instead. Here is an example on a front-end and back-end implementation of a simple "echo" API.
The API logic is simple: when you make a GET request to the API with `?name=foobar`, it returns `Hello foobar`. Here is the backend implementation in your plugin code.
```go
embedWebRouter.HandleFunc("/api/echo", func(w http.ResponseWriter, r *http.Request) {
// This is a simple echo API that will return the request body as response
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "Missing 'name' query parameter", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
response := map[string]string{"message": fmt.Sprintf("Hello %s", name)}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}, nil)
```
And here is the front-end code in your HTML file
```html
<!-- The example below show how HTTP GET is used with Zoraxy plugin -->
<h3>Echo Test (HTTP GET)</h3>
<div class="ui form">
<div class="field">
<label for="nameInput">Enter your name:</label>
<input type="text" id="nameInput" placeholder="Your name">
</div>
<button class="ui button primary" id="sendRequestButton">Send Request</button>
<div class="ui message" id="responseMessage" style="display: none;"></div>
</div>
<script>
document.getElementById('sendRequestButton').addEventListener('click', function() {
const name = document.getElementById('nameInput').value;
if (name.trim() === "") {
alert("Please enter a name.");
return;
}
// Note the relative path is used here!
// GET do not require CSRF token, so you can use $.ajax directly
// or $.cjax (defined in /script/utils.js) to make GET request
$.ajax({
url: `./api/echo`,
type: 'GET',
data: { name: name },
success: function(data) {
console.log('Response:', data.message);
$('#responseMessage').text(data.message).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#responseMessage').text('An error occurred while processing your request.').show();
}
});
});
</script>
```
### Making POST request
Making POST request is also similar to GET request, except when making the request, you will need pass the CSRF-Token with the payload. This is required due to security reasons (See [#267](https://github.com/tobychui/zoraxy/issues/267) for more details).
Since the CSRF validation is done by Zoraxy, your plugin backend code can be implemented just like an ordinary handler. Here is an example POST handling function that receive a FORM POST and print it in an HTML response.
```go
embedWebRouter.HandleFunc("/api/post", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
for key, values := range r.PostForm {
for _, value := range values {
// Generate a simple HTML response
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, "%s: %s<br>", key, value)
}
}
}, nil)
```
For the front-end, you will need to use the `$.cjax` function implemented in Zoraxy `utils.js` file. You can include this file by adding these two lines to your HTML file.
```html
<script src="/script/jquery-3.6.0.min.js"></script>
<script src="/script/utils.js"></script>
<!- More lines here -->
<!-- The example below shows how form post can be used in plugin -->
<h3>Form Post Test (HTTP POST)</h3>
<div class="ui form">
<div class="field">
<label for="postNameInput">Name:</label>
<input type="text" id="postNameInput" placeholder="Your name">
</div>
<div class="field">
<label for="postAgeInput">Age:</label>
<input type="number" id="postAgeInput" placeholder="Your age">
</div>
<div class="field">
<label>Gender:</label>
<div class="ui checkbox">
<input type="checkbox" id="genderMale" name="gender" value="Male">
<label for="genderMale">Male</label>
</div>
<div class="ui checkbox">
<input type="checkbox" id="genderFemale" name="gender" value="Female">
<label for="genderFemale">Female</label>
</div>
</div>
<button class="ui button primary" id="postRequestButton">Send</button>
<div class="ui message" id="postResponseMessage" style="display: none;"></div>
</div>
```
After that, you can call to the `$.cjax` function just like what you would usually do with the `$ajax` function.
```javascript
// .cjax (defined in /script/utils.js) is used to make POST request with CSRF token support
// alternatively you can use $.ajax with CSRF token in headers
// the header is named "X-CSRF-Token" and the value is taken from the head
// meta tag content (i.e. <meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">)
$.cjax({
url: './api/post',
type: 'POST',
data: { name: name, age: age, gender: gender },
success: function(data) {
console.log('Response:', data);
$('#postResponseMessage').html(data).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#postResponseMessage').text('An error occurred while processing your request.').show();
}
});
```
### POST Request with Vanilla JS
It is possible to make POST request with Vanilla JS. Note that you will need to populate the csrf-token field yourself to make the request pass through the plugin UI request router in Zoraxy. Here is a basic example on how it could be done.
```javascript
fetch('./api/post', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Include the CSRF token in the headers
},
body: JSON.stringify({{your_data_here}})
})
```
---
## 5. Full Code
Here is the full code of the RESTFUL example for reference.
Front-end (`plugins/restful-example/www/index.html`)
```html
<!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}}">
<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">
<title>RESTful Example</title>
<style>
body {
background-color: var(--theme_bg_primary);
}
</style>
</head>
<body>
<!-- Dark theme script must be included after body tag-->
<link rel="stylesheet" href="/darktheme.css">
<script src="/script/darktheme.js"></script>
<br>
<div class="standardContainer">
<div class="ui container">
<h1>RESTFul API Example</h1>
<!-- The example below show how HTTP GET is used with Zoraxy plugin -->
<h3>Echo Test (HTTP GET)</h3>
<div class="ui form">
<div class="field">
<label for="nameInput">Enter your name:</label>
<input type="text" id="nameInput" placeholder="Your name">
</div>
<button class="ui button primary" id="sendRequestButton">Send Request</button>
<div class="ui message" id="responseMessage" style="display: none;"></div>
</div>
<script>
document.getElementById('sendRequestButton').addEventListener('click', function() {
const name = document.getElementById('nameInput').value;
if (name.trim() === "") {
alert("Please enter a name.");
return;
}
// Note the relative path is used here!
// GET do not require CSRF token, so you can use $.ajax directly
// or $.cjax (defined in /script/utils.js) to make GET request
$.ajax({
url: `./api/echo`,
type: 'GET',
data: { name: name },
success: function(data) {
console.log('Response:', data.message);
$('#responseMessage').text(data.message).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#responseMessage').text('An error occurred while processing your request.').show();
}
});
});
</script>
<!-- The example below shows how form post can be used in plugin -->
<h3>Form Post Test (HTTP POST)</h3>
<div class="ui form">
<div class="field">
<label for="postNameInput">Name:</label>
<input type="text" id="postNameInput" placeholder="Your name">
</div>
<div class="field">
<label for="postAgeInput">Age:</label>
<input type="number" id="postAgeInput" placeholder="Your age">
</div>
<div class="field">
<label>Gender:</label>
<div class="ui checkbox">
<input type="checkbox" id="genderMale" name="gender" value="Male">
<label for="genderMale">Male</label>
</div>
<div class="ui checkbox">
<input type="checkbox" id="genderFemale" name="gender" value="Female">
<label for="genderFemale">Female</label>
</div>
</div>
<button class="ui button primary" id="postRequestButton">Send</button>
<div class="ui message" id="postResponseMessage" style="display: none;"></div>
</div>
<script>
document.getElementById('postRequestButton').addEventListener('click', function() {
const name = document.getElementById('postNameInput').value;
const age = document.getElementById('postAgeInput').value;
const genderMale = document.getElementById('genderMale').checked;
const genderFemale = document.getElementById('genderFemale').checked;
if (name.trim() === "" || age.trim() === "" || (!genderMale && !genderFemale)) {
alert("Please fill out all fields.");
return;
}
const gender = genderMale ? "Male" : "Female";
// .cjax (defined in /script/utils.js) is used to make POST request with CSRF token support
// alternatively you can use $.ajax with CSRF token in headers
// the header is named "X-CSRF-Token" and the value is taken from the head
// meta tag content (i.e. <meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">)
$.cjax({
url: './api/post',
type: 'POST',
data: { name: name, age: age, gender: gender },
success: function(data) {
console.log('Response:', data);
$('#postResponseMessage').html(data).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#postResponseMessage').text('An error occurred while processing your request.').show();
}
});
});
</script>
</div>
</div>
</body>
</html>
```
Backend (`plugins/restful-example/main.go`)
```go
package main
import (
"embed"
_ "embed"
"encoding/json"
"fmt"
"net/http"
"strconv"
plugin "example.com/zoraxy/restful-example/mod/zoraxy_plugin"
)
const (
PLUGIN_ID = "com.example.restful-example"
UI_PATH = "/"
WEB_ROOT = "/www"
)
//go:embed www/*
var content embed.FS
func main() {
// Serve the plugin intro spect
// This will print the plugin intro spect and exit if the -introspect flag is provided
runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{
ID: "com.example.restful-example",
Name: "Restful Example",
Author: "foobar",
AuthorContact: "admin@example.com",
Description: "A simple demo for making RESTful API calls in plugin",
URL: "https://example.com",
Type: plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
// As this is a utility plugin, we don't need to capture any traffic
// but only serve the UI, so we set the UI (relative to the plugin path) to "/"
UIPath: UI_PATH,
})
if err != nil {
//Terminate or enter standalone mode here
panic(err)
}
// Create a new PluginEmbedUIRouter that will serve the UI from web folder
// The router will also help to handle the termination of the plugin when
// a user wants to stop the plugin via Zoraxy Web UI
embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH)
embedWebRouter.RegisterTerminateHandler(func() {
// Do cleanup here if needed
fmt.Println("Restful-example Exited")
}, nil)
//Register a simple API endpoint that will echo the request body
// Since we are using the default http.ServeMux, we can register the handler directly with the last
// parameter as nil
embedWebRouter.HandleFunc("/api/echo", func(w http.ResponseWriter, r *http.Request) {
// This is a simple echo API that will return the request body as response
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "Missing 'name' query parameter", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
response := map[string]string{"message": fmt.Sprintf("Hello %s", name)}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}, nil)
// Here is another example of a POST API endpoint that will echo the form data
// This will handle POST requests to /api/post and return the form data as response
embedWebRouter.HandleFunc("/api/post", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
for key, values := range r.PostForm {
for _, value := range values {
// Generate a simple HTML response
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, "%s: %s<br>", key, value)
}
}
}, nil)
// Serve the restful-example page in the www folder
http.Handle(UI_PATH, embedWebRouter.Handler())
fmt.Println("Restful-example 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)
}
}
```
What you should expect to see if everything is correctly loaded and working in Zoray
![image-20250530153148506](img/2. RESTful Example/image-20250530153148506.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -1,3 +1,7 @@
<div class="ts-image is-rounded">
<img src="../assets/banner.png" alt="Zoraxy Plugin Banner" style="width: 100%; height: auto; border-radius: 0.5rem;">
</div>
# Index
Welcome to the Zoraxy Plugin Documentation!
@ -14,4 +18,10 @@ No. Plugins operate in a separate process from Zoraxy. If a plugin crashes, Zora
Yes, the plugin library and interface design are open source under the LGPL license. You are not required to disclose the source code of your plugin as long as you do not modify the plugin library and use it as-is. For more details on how to comply with the license, refer to the licensing documentation.
### How can I add my plugin to the official plugin store?
To add your plugin to the official plugin store, open a pull request (PR) in the plugin repository.
To add your plugin to the official plugin store, open a pull request (PR) in the plugin repository.
## GNU Free Documentation License
This documentation is licensed under the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. You may copy, distribute, and modify this document under the terms of the license.
A copy of the license is available at [https://www.gnu.org/licenses/fdl-1.3.html](https://www.gnu.org/licenses/fdl-1.3.html).

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index
@ -158,7 +161,7 @@
<p>
<p class="ts-text">
<span class="ts-text is-heavy">
It is generally recommended that a plugin have such UI exposed for easy configurations.
A plugin must provide a UI, as it is part of the control mechanism of the plugin life cycle. (i.e. Zoraxy use the plugin UI HTTP server to communicate with the plugin for control signals)
</span>
As plugin installed via plugin store provides limited ways for a user to configure the plugin, the plugin web UI will be the best way for user to setup your plugin.
</p>

View File

@ -131,6 +131,9 @@
<a class="item is-active" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index
@ -526,6 +529,11 @@ if err != nil {
After you are done, restart Zoraxy and enable your plugin in the Plugin List. Now you can test and debug your plugin with your HTTP Proxy Rules. All the STDOUT and STDERR of your plugin will be forwarded to the STDOUT of Zoraxy as well as the log file.
</p>
</p>
<p>
<div class="ts-image is-rounded" style="max-width: 800px">
<img src="img/1. Hello World/image-20250530134148399.png" alt="image-20250530134148399" />
</div>
</p>
<p>
<p class="ts-text">
<span class="ts-text is-heavy">

View File

@ -0,0 +1,787 @@
<!DOCTYPE html>
<html lang="en" class="is-white">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
RESTful Example | Zoraxy Documentation
</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js" integrity="sha512-LhccdVNGe2QMEfI3x4DVV3ckMRe36TfydKss6mJpdHjNFiV07dFpS2xzeZedptKZrwxfICJpez09iNioiSZ3hA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<!-- css -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/5.0.2/tocas.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/5.0.2/tocas.min.js"></script>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Code highlight -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<!-- additional languages -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/go.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/c.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/xml.min.js"></script>
<script>
hljs.highlightAll();
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/tomorrow-night-bright.css">
<style>
#msgbox{
position: fixed;
bottom: 1em;
right: 1em;
z-index: 9999;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
dialog[open] {
animation: fadeIn 0.3s ease-in-out;
}
code{
border-radius: 0.5rem;
}
</style>
<script src="/html/assets/theme.js"></script>
</head>
<body>
<div class="ts-content">
<div class="ts-container">
<div style="float: right;">
<button class="ts-button is-icon" id="darkModeToggle">
<span class="ts-icon is-moon-icon"></span>
</button>
</div>
<div class="ts-tab is-pilled">
<a href="" class="item" style="user-select: none;">
<img id="sysicon" class="ts-image" style="height: 30px" white_src="/html/assets/logo.png" dark_src="/html/assets/logo_white.png" src="/html/assets/logo.png"></img>
</a>
<a href="#!" class="is-active item">
Documents
</a>
<a href="#!" class="item">
Examples
</a>
</div>
</div>
</div>
<div class="ts-divider"></div>
<div>
<div class="has-padded">
<div class="ts-grid mobile:is-stacked">
<div class="column is-4-wide">
<div class="ts-box">
<div class="ts-menu is-end-icon">
<a class="item">
Introduction
<span class="ts-icon is-caret-down-icon"></span>
</a>
<div class="ts-menu is-dense is-small is-horizontally-padded">
<a class="item" href="/html/1. Introduction/1. What is Zoraxy Plugin.html">
What is Zoraxy Plugin
</a>
<a class="item" href="/html/1. Introduction/2. Getting Started.html">
Getting Started
</a>
<a class="item" href="/html/1. Introduction/3. Installing Plugin.html">
Installing Plugin
</a>
<a class="item" href="/html/1. Introduction/4. Enable Plugins.html">
Enable Plugins
</a>
</div>
<a class="item">
Architecture
<span class="ts-icon is-caret-down-icon"></span>
</a>
<div class="ts-menu is-dense is-small is-horizontally-padded">
<a class="item" href="/html/2. Architecture/1. Plugin Architecture.html">
Plugin Architecture
</a>
<a class="item" href="/html/2. Architecture/2. Introspect.html">
Introspect
</a>
<a class="item" href="/html/2. Architecture/3. Configure.html">
Configure
</a>
<a class="item" href="/html/2. Architecture/4. Capture Modes.html">
Capture Modes
</a>
<a class="item" href="/html/2. Architecture/5. Plugin UI.html">
Plugin UI
</a>
</div>
<a class="item">
Basic Examples
<span class="ts-icon is-caret-down-icon"></span>
</a>
<div class="ts-menu is-dense is-small is-horizontally-padded">
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item is-active" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item" href="/html/index.html">
index
</a>
</div>
</div>
</div>
<div class="column is-12-wide">
<div class="ts-box">
<div class="ts-container is-padded has-top-padded-large">
<h1 id="restful-api-call-in-web-ui">
RESTful API Call in Web UI
</h1>
<p>
<p class="ts-text">
Last Update: 29/05/2025
</p>
</p>
<div class="ts-divider has-top-spaced-large"></div>
<p>
<p class="ts-text">
When developing a UI for your plugin, sometime you might need to make RESTFUL API calls to your plugin backend for setting up something or getting latest information from your plugin. In this example, I will show you how to create a plugin with RESTful api call capabilities with the embedded web server and the custom
<span class="ts-text is-code">
cjax
</span>
function.
</p>
</p>
<p>
<p class="ts-text">
<span class="ts-text is-heavy">
Notes: This example assumes you have basic understanding on how to use jQuery
<span class="ts-text is-code">
ajax
</span>
request.
</span>
</p>
</p>
<p>
<p class="ts-text">
Lets get started!
</p>
</p>
<div class="ts-divider has-top-spaced-large"></div>
<h2 id="1-create-the-plugin-folder-structures">
1. Create the plugin folder structures
</h2>
<p>
<p class="ts-text">
This step is identical to the Hello World example, where you create a plugin folder with the required go project structure in the folder. Please refer to the Hello World example section 1 to 5 for details.
</p>
</p>
<div class="ts-divider has-top-spaced-large"></div>
<h2 id="2-create-introspect">
2. Create Introspect
</h2>
<p>
<p class="ts-text">
This is quite similar to the Hello World example as well, but we are changing some of the IDs to match what we want to do in this plugin.
</p>
</p>
<pre><code class="language-go">runtimeCfg, err := plugin.ServeAndRecvSpec(&amp;plugin.IntroSpect{
ID: &quot;com.example.restful-example&quot;,
Name: &quot;Restful Example&quot;,
Author: &quot;foobar&quot;,
AuthorContact: &quot;admin@example.com&quot;,
Description: &quot;A simple demo for making RESTful API calls in plugin&quot;,
URL: &quot;https://example.com&quot;,
Type: plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
// As this is a utility plugin, we don't need to capture any traffic
// but only serve the UI, so we set the UI (relative to the plugin path) to &quot;/&quot;
UIPath: UI_PATH,
})
if err != nil {
//Terminate or enter standalone mode here
panic(err)
}
</code></pre>
<div class="ts-divider has-top-spaced-large"></div>
<h2 id="3-create-an-embedded-web-server-with-handlers">
3. Create an embedded web server with handlers
</h2>
<p>
<p class="ts-text">
In this step, we create a basic embedded web file handler similar to the Hello World example, however, we will need to add a
<span class="ts-text is-code">
http.HandleFunc
</span>
to the plugin so our front-end can request and communicate with the backend.
</p>
</p>
<pre><code class="language-go">embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &amp;content, WEB_ROOT, UI_PATH)
embedWebRouter.RegisterTerminateHandler(func() {
fmt.Println(&quot;Restful-example Exited&quot;)
}, nil)
//Register a simple API endpoint that will echo the request body
// Since we are using the default http.ServeMux, we can register the handler directly with the last
// parameter as nil
embedWebRouter.HandleFunc(&quot;/api/echo&quot;, func(w http.ResponseWriter, r *http.Request) {
//Some handler code here
}, nil)
</code></pre>
<p>
<p class="ts-text">
The
<span class="ts-text is-code">
embedWebRouter.HandleFunc
</span>
last parameter is the
<span class="ts-text is-code">
http.Mux
</span>
, where if you have multiple web server listening interface, you can fill in different Mux based on your implementation. On of the examples is that, when you are developing a static web server plugin, where you need a dedicated HTTP listening endpoint that is not the one Zoraxy assigned to your plugin, you need to create two http.Mux and assign one of them for Zoraxy plugin UI purpose.
</p>
</p>
<div class="ts-divider has-top-spaced-large"></div>
<h2 id="4-modify-the-front-end-html-file-to-make-request-to-backend">
4. Modify the front-end HTML file to make request to backend
</h2>
<p>
<p class="ts-text">
To make a RESTFUL API to your plugin,
<span class="ts-text is-heavy">
you must use relative path in your request URL
</span>
.
</p>
</p>
<p>
<p class="ts-text">
Absolute path that start with
<span class="ts-text is-code">
/
</span>
is only use for accessing Zoraxy resouces. For example, when you access
<span class="ts-text is-code">
/img/logo.svg
</span>
, Zoraxy webmin HTTP router will return the logo of Zoraxy for you instead of
<span class="ts-text is-code">
/plugins/your_plugin_name/{your_web_root}/img/logo.svg
</span>
.
</p>
</p>
<h3 id="making-get-request">
Making GET request
</h3>
<p>
Making GET request is similar to what you would do in ordinary web development, but only limited to relative paths like
<span class="ts-text is-code">
./api/foo/bar
</span>
instead. Here is an example on a front-end and back-end implementation of a simple &ldquo;echo&rdquo; API.
</p>
<p>
<p class="ts-text">
The API logic is simple: when you make a GET request to the API with
<span class="ts-text is-code">
?name=foobar
</span>
, it returns
<span class="ts-text is-code">
Hello foobar
</span>
. Here is the backend implementation in your plugin code.
</p>
</p>
<pre><code class="language-go">embedWebRouter.HandleFunc(&quot;/api/echo&quot;, func(w http.ResponseWriter, r *http.Request) {
// This is a simple echo API that will return the request body as response
name := r.URL.Query().Get(&quot;name&quot;)
if name == &quot;&quot; {
http.Error(w, &quot;Missing 'name' query parameter&quot;, http.StatusBadRequest)
return
}
w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
response := map[string]string{&quot;message&quot;: fmt.Sprintf(&quot;Hello %s&quot;, name)}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, &quot;Failed to encode response&quot;, http.StatusInternalServerError)
}
}, nil)
</code></pre>
<p>
<p class="ts-text">
And here is the front-end code in your HTML file
</p>
</p>
<pre><code class="language-html">&lt;!-- The example below show how HTTP GET is used with Zoraxy plugin --&gt;
&lt;h3&gt;Echo Test (HTTP GET)&lt;/h3&gt;
&lt;div class=&quot;ui form&quot;&gt;
&lt;div class=&quot;field&quot;&gt;
&lt;label for=&quot;nameInput&quot;&gt;Enter your name:&lt;/label&gt;
&lt;input type=&quot;text&quot; id=&quot;nameInput&quot; placeholder=&quot;Your name&quot;&gt;
&lt;/div&gt;
&lt;button class=&quot;ui button primary&quot; id=&quot;sendRequestButton&quot;&gt;Send Request&lt;/button&gt;
&lt;div class=&quot;ui message&quot; id=&quot;responseMessage&quot; style=&quot;display: none;&quot;&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
document.getElementById('sendRequestButton').addEventListener('click', function() {
const name = document.getElementById('nameInput').value;
if (name.trim() === &quot;&quot;) {
alert(&quot;Please enter a name.&quot;);
return;
}
// Note the relative path is used here!
// GET do not require CSRF token, so you can use $.ajax directly
// or $.cjax (defined in /script/utils.js) to make GET request
$.ajax({
url: `./api/echo`,
type: 'GET',
data: { name: name },
success: function(data) {
console.log('Response:', data.message);
$('#responseMessage').text(data.message).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#responseMessage').text('An error occurred while processing your request.').show();
}
});
});
&lt;/script&gt;
</code></pre>
<h3 id="making-post-request">
Making POST request
</h3>
<p>
<p class="ts-text">
Making POST request is also similar to GET request, except when making the request, you will need pass the CSRF-Token with the payload. This is required due to security reasons (See
<a href="https://github.com/tobychui/zoraxy/issues/267" target="_blank">
#267
</a>
for more details).
</p>
</p>
<p>
<p class="ts-text">
Since the CSRF validation is done by Zoraxy, your plugin backend code can be implemented just like an ordinary handler. Here is an example POST handling function that receive a FORM POST and print it in an HTML response.
</p>
</p>
<pre><code class="language-go">embedWebRouter.HandleFunc(&quot;/api/post&quot;, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, &quot;Invalid request method&quot;, http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, &quot;Failed to parse form data&quot;, http.StatusBadRequest)
return
}
for key, values := range r.PostForm {
for _, value := range values {
// Generate a simple HTML response
w.Header().Set(&quot;Content-Type&quot;, &quot;text/html&quot;)
fmt.Fprintf(w, &quot;%s: %s&lt;br&gt;&quot;, key, value)
}
}
}, nil)
</code></pre>
<p>
<p class="ts-text">
For the front-end, you will need to use the
<span class="ts-text is-code">
$.cjax
</span>
function implemented in Zoraxy
<span class="ts-text is-code">
utils.js
</span>
file. You can include this file by adding these two lines to your HTML file.
</p>
</p>
<pre><code class="language-html">&lt;script src=&quot;/script/jquery-3.6.0.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;/script/utils.js&quot;&gt;&lt;/script&gt;
&lt;!- More lines here --&gt;
&lt;!-- The example below shows how form post can be used in plugin --&gt;
&lt;h3&gt;Form Post Test (HTTP POST)&lt;/h3&gt;
&lt;div class=&quot;ui form&quot;&gt;
&lt;div class=&quot;field&quot;&gt;
&lt;label for=&quot;postNameInput&quot;&gt;Name:&lt;/label&gt;
&lt;input type=&quot;text&quot; id=&quot;postNameInput&quot; placeholder=&quot;Your name&quot;&gt;
&lt;/div&gt;
&lt;div class=&quot;field&quot;&gt;
&lt;label for=&quot;postAgeInput&quot;&gt;Age:&lt;/label&gt;
&lt;input type=&quot;number&quot; id=&quot;postAgeInput&quot; placeholder=&quot;Your age&quot;&gt;
&lt;/div&gt;
&lt;div class=&quot;field&quot;&gt;
&lt;label&gt;Gender:&lt;/label&gt;
&lt;div class=&quot;ui checkbox&quot;&gt;
&lt;input type=&quot;checkbox&quot; id=&quot;genderMale&quot; name=&quot;gender&quot; value=&quot;Male&quot;&gt;
&lt;label for=&quot;genderMale&quot;&gt;Male&lt;/label&gt;
&lt;/div&gt;
&lt;div class=&quot;ui checkbox&quot;&gt;
&lt;input type=&quot;checkbox&quot; id=&quot;genderFemale&quot; name=&quot;gender&quot; value=&quot;Female&quot;&gt;
&lt;label for=&quot;genderFemale&quot;&gt;Female&lt;/label&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;button class=&quot;ui button primary&quot; id=&quot;postRequestButton&quot;&gt;Send&lt;/button&gt;
&lt;div class=&quot;ui message&quot; id=&quot;postResponseMessage&quot; style=&quot;display: none;&quot;&gt;&lt;/div&gt;
&lt;/div&gt;
</code></pre>
<p>
<p class="ts-text">
After that, you can call to the
<span class="ts-text is-code">
$.cjax
</span>
function just like what you would usually do with the
<span class="ts-text is-code">
$ajax
</span>
function.
</p>
</p>
<pre><code class="language-javascript">// .cjax (defined in /script/utils.js) is used to make POST request with CSRF token support
// alternatively you can use $.ajax with CSRF token in headers
// the header is named &quot;X-CSRF-Token&quot; and the value is taken from the head
// meta tag content (i.e. &lt;meta name=&quot;zoraxy.csrf.Token&quot; content=&quot;{{.csrfToken}}&quot;&gt;)
$.cjax({
url: './api/post',
type: 'POST',
data: { name: name, age: age, gender: gender },
success: function(data) {
console.log('Response:', data);
$('#postResponseMessage').html(data).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#postResponseMessage').text('An error occurred while processing your request.').show();
}
});
</code></pre>
<h3 id="post-request-with-vanilla-js">
POST Request with Vanilla JS
</h3>
<p>
<p class="ts-text">
It is possible to make POST request with Vanilla JS. Note that you will need to populate the csrf-token field yourself to make the request pass through the plugin UI request router in Zoraxy. Here is a basic example on how it could be done.
</p>
</p>
<pre><code class="language-javascript">fetch('./api/post', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Include the CSRF token in the headers
},
body: JSON.stringify({{your_data_here}})
})
</code></pre>
<div class="ts-divider has-top-spaced-large"></div>
<h2 id="5-full-code">
5. Full Code
</h2>
<p>
<p class="ts-text">
Here is the full code of the RESTFUL example for reference.
</p>
</p>
<p>
<p class="ts-text">
Front-end (
<span class="ts-text is-code">
plugins/restful-example/www/index.html
</span>
)
</p>
</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;!-- CSRF token, if your plugin need to make POST request to backend --&gt;
&lt;meta name=&quot;zoraxy.csrf.Token&quot; content=&quot;{{.csrfToken}}&quot;&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;/script/semantic/semantic.min.css&quot;&gt;
&lt;script src=&quot;/script/jquery-3.6.0.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;/script/semantic/semantic.min.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;/script/utils.js&quot;&gt;&lt;/script&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;/main.css&quot;&gt;
&lt;title&gt;RESTful Example&lt;/title&gt;
&lt;style&gt;
body {
background-color: var(--theme_bg_primary);
}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;!-- Dark theme script must be included after body tag--&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;/darktheme.css&quot;&gt;
&lt;script src=&quot;/script/darktheme.js&quot;&gt;&lt;/script&gt;
&lt;br&gt;
&lt;div class=&quot;standardContainer&quot;&gt;
&lt;div class=&quot;ui container&quot;&gt;
&lt;h1&gt;RESTFul API Example&lt;/h1&gt;
&lt;!-- The example below show how HTTP GET is used with Zoraxy plugin --&gt;
&lt;h3&gt;Echo Test (HTTP GET)&lt;/h3&gt;
&lt;div class=&quot;ui form&quot;&gt;
&lt;div class=&quot;field&quot;&gt;
&lt;label for=&quot;nameInput&quot;&gt;Enter your name:&lt;/label&gt;
&lt;input type=&quot;text&quot; id=&quot;nameInput&quot; placeholder=&quot;Your name&quot;&gt;
&lt;/div&gt;
&lt;button class=&quot;ui button primary&quot; id=&quot;sendRequestButton&quot;&gt;Send Request&lt;/button&gt;
&lt;div class=&quot;ui message&quot; id=&quot;responseMessage&quot; style=&quot;display: none;&quot;&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
document.getElementById('sendRequestButton').addEventListener('click', function() {
const name = document.getElementById('nameInput').value;
if (name.trim() === &quot;&quot;) {
alert(&quot;Please enter a name.&quot;);
return;
}
// Note the relative path is used here!
// GET do not require CSRF token, so you can use $.ajax directly
// or $.cjax (defined in /script/utils.js) to make GET request
$.ajax({
url: `./api/echo`,
type: 'GET',
data: { name: name },
success: function(data) {
console.log('Response:', data.message);
$('#responseMessage').text(data.message).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#responseMessage').text('An error occurred while processing your request.').show();
}
});
});
&lt;/script&gt;
&lt;!-- The example below shows how form post can be used in plugin --&gt;
&lt;h3&gt;Form Post Test (HTTP POST)&lt;/h3&gt;
&lt;div class=&quot;ui form&quot;&gt;
&lt;div class=&quot;field&quot;&gt;
&lt;label for=&quot;postNameInput&quot;&gt;Name:&lt;/label&gt;
&lt;input type=&quot;text&quot; id=&quot;postNameInput&quot; placeholder=&quot;Your name&quot;&gt;
&lt;/div&gt;
&lt;div class=&quot;field&quot;&gt;
&lt;label for=&quot;postAgeInput&quot;&gt;Age:&lt;/label&gt;
&lt;input type=&quot;number&quot; id=&quot;postAgeInput&quot; placeholder=&quot;Your age&quot;&gt;
&lt;/div&gt;
&lt;div class=&quot;field&quot;&gt;
&lt;label&gt;Gender:&lt;/label&gt;
&lt;div class=&quot;ui checkbox&quot;&gt;
&lt;input type=&quot;checkbox&quot; id=&quot;genderMale&quot; name=&quot;gender&quot; value=&quot;Male&quot;&gt;
&lt;label for=&quot;genderMale&quot;&gt;Male&lt;/label&gt;
&lt;/div&gt;
&lt;div class=&quot;ui checkbox&quot;&gt;
&lt;input type=&quot;checkbox&quot; id=&quot;genderFemale&quot; name=&quot;gender&quot; value=&quot;Female&quot;&gt;
&lt;label for=&quot;genderFemale&quot;&gt;Female&lt;/label&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;button class=&quot;ui button primary&quot; id=&quot;postRequestButton&quot;&gt;Send&lt;/button&gt;
&lt;div class=&quot;ui message&quot; id=&quot;postResponseMessage&quot; style=&quot;display: none;&quot;&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;script&gt;
document.getElementById('postRequestButton').addEventListener('click', function() {
const name = document.getElementById('postNameInput').value;
const age = document.getElementById('postAgeInput').value;
const genderMale = document.getElementById('genderMale').checked;
const genderFemale = document.getElementById('genderFemale').checked;
if (name.trim() === &quot;&quot; || age.trim() === &quot;&quot; || (!genderMale &amp;&amp; !genderFemale)) {
alert(&quot;Please fill out all fields.&quot;);
return;
}
const gender = genderMale ? &quot;Male&quot; : &quot;Female&quot;;
// .cjax (defined in /script/utils.js) is used to make POST request with CSRF token support
// alternatively you can use $.ajax with CSRF token in headers
// the header is named &quot;X-CSRF-Token&quot; and the value is taken from the head
// meta tag content (i.e. &lt;meta name=&quot;zoraxy.csrf.Token&quot; content=&quot;{{.csrfToken}}&quot;&gt;)
$.cjax({
url: './api/post',
type: 'POST',
data: { name: name, age: age, gender: gender },
success: function(data) {
console.log('Response:', data);
$('#postResponseMessage').html(data).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#postResponseMessage').text('An error occurred while processing your request.').show();
}
});
});
&lt;/script&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>
<p class="ts-text">
Backend (
<span class="ts-text is-code">
plugins/restful-example/main.go
</span>
)
</p>
</p>
<pre><code class="language-go">package main
import (
&quot;embed&quot;
_ &quot;embed&quot;
&quot;encoding/json&quot;
&quot;fmt&quot;
&quot;net/http&quot;
&quot;strconv&quot;
plugin &quot;example.com/zoraxy/restful-example/mod/zoraxy_plugin&quot;
)
const (
PLUGIN_ID = &quot;com.example.restful-example&quot;
UI_PATH = &quot;/&quot;
WEB_ROOT = &quot;/www&quot;
)
//go:embed www/*
var content embed.FS
func main() {
// Serve the plugin intro spect
// This will print the plugin intro spect and exit if the -introspect flag is provided
runtimeCfg, err := plugin.ServeAndRecvSpec(&amp;plugin.IntroSpect{
ID: &quot;com.example.restful-example&quot;,
Name: &quot;Restful Example&quot;,
Author: &quot;foobar&quot;,
AuthorContact: &quot;admin@example.com&quot;,
Description: &quot;A simple demo for making RESTful API calls in plugin&quot;,
URL: &quot;https://example.com&quot;,
Type: plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
// As this is a utility plugin, we don't need to capture any traffic
// but only serve the UI, so we set the UI (relative to the plugin path) to &quot;/&quot;
UIPath: UI_PATH,
})
if err != nil {
//Terminate or enter standalone mode here
panic(err)
}
// Create a new PluginEmbedUIRouter that will serve the UI from web folder
// The router will also help to handle the termination of the plugin when
// a user wants to stop the plugin via Zoraxy Web UI
embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &amp;content, WEB_ROOT, UI_PATH)
embedWebRouter.RegisterTerminateHandler(func() {
// Do cleanup here if needed
fmt.Println(&quot;Restful-example Exited&quot;)
}, nil)
//Register a simple API endpoint that will echo the request body
// Since we are using the default http.ServeMux, we can register the handler directly with the last
// parameter as nil
embedWebRouter.HandleFunc(&quot;/api/echo&quot;, func(w http.ResponseWriter, r *http.Request) {
// This is a simple echo API that will return the request body as response
name := r.URL.Query().Get(&quot;name&quot;)
if name == &quot;&quot; {
http.Error(w, &quot;Missing 'name' query parameter&quot;, http.StatusBadRequest)
return
}
w.Header().Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
response := map[string]string{&quot;message&quot;: fmt.Sprintf(&quot;Hello %s&quot;, name)}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, &quot;Failed to encode response&quot;, http.StatusInternalServerError)
}
}, nil)
// Here is another example of a POST API endpoint that will echo the form data
// This will handle POST requests to /api/post and return the form data as response
embedWebRouter.HandleFunc(&quot;/api/post&quot;, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, &quot;Invalid request method&quot;, http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, &quot;Failed to parse form data&quot;, http.StatusBadRequest)
return
}
for key, values := range r.PostForm {
for _, value := range values {
// Generate a simple HTML response
w.Header().Set(&quot;Content-Type&quot;, &quot;text/html&quot;)
fmt.Fprintf(w, &quot;%s: %s&lt;br&gt;&quot;, key, value)
}
}
}, nil)
// Serve the restful-example page in the www folder
http.Handle(UI_PATH, embedWebRouter.Handler())
fmt.Println(&quot;Restful-example started at http://127.0.0.1:&quot; + strconv.Itoa(runtimeCfg.Port))
err = http.ListenAndServe(&quot;127.0.0.1:&quot;+strconv.Itoa(runtimeCfg.Port), nil)
if err != nil {
panic(err)
}
}
</code></pre>
<p>
<p class="ts-text">
What you should expect to see if everything is correctly loaded and working in Zoray
</p>
</p>
<p>
<div class="ts-image is-rounded" style="max-width: 800px">
<img src="img/2. RESTful Example/image-20250530153148506.png" alt="image-20250530153148506" />
</div>
</p>
</div>
<br>
<br>
</div>
</div>
</div>
</div>
</div>
<div class="ts-container">
<div class="ts-divider"></div>
<div class="ts-content">
<div class="ts-text">
Zoraxy © tobychui
<span class="thisyear">
2025
</span>
</div>
</div>
</div>
<script>
$(".thisyear").text(new Date().getFullYear());
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -131,6 +131,9 @@
<a class="item" href="/html/3. Basic Examples/1. Hello World.html">
Hello World
</a>
<a class="item" href="/html/3. Basic Examples/2. RESTful Example.html">
RESTful Example
</a>
</div>
<a class="item is-active" href="/html/index.html">
index
@ -141,6 +144,9 @@
<div class="column is-12-wide">
<div class="ts-box">
<div class="ts-container is-padded has-top-padded-large">
<div class="ts-image is-rounded">
<img src="../assets/banner.png" alt="Zoraxy Plugin Banner" style="width: 100%; height: auto; border-radius: 0.5rem;">
</div>
<h1 id="index">
Index
</h1>
@ -184,6 +190,23 @@
To add your plugin to the official plugin store, open a pull request (PR) in the plugin repository.
</p>
</p>
<h2 id="gnu-free-documentation-license">
GNU Free Documentation License
</h2>
<p>
<p class="ts-text">
This documentation is licensed under the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. You may copy, distribute, and modify this document under the terms of the license.
</p>
</p>
<p>
<p class="ts-text">
A copy of the license is available at
<a href="https://www.gnu.org/licenses/fdl-1.3.html" target="_blank">
https://www.gnu.org/licenses/fdl-1.3.html
</a>
.
</p>
</p>
</div>
<br>
<br>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="0; url=html/1.%20Introduction/1.%20Getting%20Started.html#!" />
<meta http-equiv="refresh" content="0; url=html/" />
<title>Redirecting...</title>
</head>
<body>

View File

@ -71,6 +71,11 @@
"filename": "3. Basic Examples/1. Hello World.md",
"title": "Hello World",
"type": "file"
},
{
"filename": "3. Basic Examples/2. RESTful Example.md",
"title": "RESTful Example",
"type": "file"
}
]
},

View File

@ -41,8 +41,8 @@ func main() {
func startWebServerInBackground() {
go func() {
server := &http.Server{Addr: ":8080", Handler: http.DefaultServeMux}
http.DefaultServeMux = http.NewServeMux()
server := &http.Server{Addr: ":8080", Handler: http.DefaultServeMux}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.FileServer(http.Dir("./")).ServeHTTP(w, r)
})

View File

@ -0,0 +1,3 @@
module example.com/zoraxy/restful-example
go 1.23.6

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,103 @@
package main
import (
"embed"
_ "embed"
"encoding/json"
"fmt"
"net/http"
"strconv"
plugin "example.com/zoraxy/restful-example/mod/zoraxy_plugin"
)
const (
PLUGIN_ID = "com.example.restful-example"
UI_PATH = "/"
WEB_ROOT = "/www"
)
//go:embed www/*
var content embed.FS
func main() {
// Serve the plugin intro spect
// This will print the plugin intro spect and exit if the -introspect flag is provided
runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{
ID: "com.example.restful-example",
Name: "Restful Example",
Author: "foobar",
AuthorContact: "admin@example.com",
Description: "A simple demo for making RESTful API calls in plugin",
URL: "https://example.com",
Type: plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
// As this is a utility plugin, we don't need to capture any traffic
// but only serve the UI, so we set the UI (relative to the plugin path) to "/"
UIPath: UI_PATH,
})
if err != nil {
//Terminate or enter standalone mode here
panic(err)
}
// Create a new PluginEmbedUIRouter that will serve the UI from web folder
// The router will also help to handle the termination of the plugin when
// a user wants to stop the plugin via Zoraxy Web UI
embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH)
embedWebRouter.RegisterTerminateHandler(func() {
// Do cleanup here if needed
fmt.Println("Restful-example Exited")
}, nil)
//Register a simple API endpoint that will echo the request body
// Since we are using the default http.ServeMux, we can register the handler directly with the last
// parameter as nil
embedWebRouter.HandleFunc("/api/echo", func(w http.ResponseWriter, r *http.Request) {
// This is a simple echo API that will return the request body as response
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "Missing 'name' query parameter", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
response := map[string]string{"message": fmt.Sprintf("Hello %s", name)}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}, nil)
// Here is another example of a POST API endpoint that will echo the form data
// This will handle POST requests to /api/post and return the form data as response
embedWebRouter.HandleFunc("/api/post", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
for key, values := range r.PostForm {
for _, value := range values {
// Generate a simple HTML response
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, "%s: %s<br>", key, value)
}
}
}, nil)
// Serve the restful-example page in the www folder
http.Handle(UI_PATH, embedWebRouter.Handler())
fmt.Println("Restful-example 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)
}
}

View 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

View 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())
}

View 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
}

View File

@ -0,0 +1,174 @@
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)
}()
})
}
// HandleFunc registers a handler function for the given pattern
// The pattern should start with the handler prefix, e.g. /ui/hello
// If the pattern does not start with the handler prefix, it will be prepended with the handler prefix
func (p *PluginUiRouter) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), mux *http.ServeMux) {
// If mux is nil, use the default ServeMux
if mux == nil {
mux = http.DefaultServeMux
}
// Make sure the pattern starts with the handler prefix
if !strings.HasPrefix(pattern, p.HandlerPrefix) {
pattern = p.HandlerPrefix + pattern
}
// Register the handler with the http.ServeMux
mux.HandleFunc(pattern, handler)
}
// 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())
}

View 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)
}
}

View File

@ -0,0 +1,176 @@
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"`
DevelopmentBuild bool `json:"development_build"` //Whether the Zoraxy is a development build or not
}
/*
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()
}

View File

@ -0,0 +1,126 @@
<!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}}">
<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">
<title>RESTful Example</title>
<style>
body {
background-color: var(--theme_bg_primary);
}
</style>
</head>
<body>
<!-- Dark theme script must be included after body tag-->
<link rel="stylesheet" href="/darktheme.css">
<script src="/script/darktheme.js"></script>
<br>
<div class="standardContainer">
<div class="ui container">
<h1>RESTFul API Example</h1>
<!-- The example below show how HTTP GET is used with Zoraxy plugin -->
<h3>Echo Test (HTTP GET)</h3>
<div class="ui form">
<div class="field">
<label for="nameInput">Enter your name:</label>
<input type="text" id="nameInput" placeholder="Your name">
</div>
<button class="ui button primary" id="sendRequestButton">Send Request</button>
<div class="ui message" id="responseMessage" style="display: none;"></div>
</div>
<script>
document.getElementById('sendRequestButton').addEventListener('click', function() {
const name = document.getElementById('nameInput').value;
if (name.trim() === "") {
alert("Please enter a name.");
return;
}
// Note the relative path is used here!
// GET do not require CSRF token, so you can use $.ajax directly
// or $.cjax (defined in /script/utils.js) to make GET request
$.ajax({
url: `./api/echo`,
type: 'GET',
data: { name: name },
success: function(data) {
console.log('Response:', data.message);
$('#responseMessage').text(data.message).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#responseMessage').text('An error occurred while processing your request.').show();
}
});
});
</script>
<!-- The example below shows how form post can be used in plugin -->
<h3>Form Post Test (HTTP POST)</h3>
<div class="ui form">
<div class="field">
<label for="postNameInput">Name:</label>
<input type="text" id="postNameInput" placeholder="Your name">
</div>
<div class="field">
<label for="postAgeInput">Age:</label>
<input type="number" id="postAgeInput" placeholder="Your age">
</div>
<div class="field">
<label>Gender:</label>
<div class="ui checkbox">
<input type="checkbox" id="genderMale" name="gender" value="Male">
<label for="genderMale">Male</label>
</div>
<div class="ui checkbox">
<input type="checkbox" id="genderFemale" name="gender" value="Female">
<label for="genderFemale">Female</label>
</div>
</div>
<button class="ui button primary" id="postRequestButton">Send</button>
<div class="ui message" id="postResponseMessage" style="display: none;"></div>
</div>
<script>
document.getElementById('postRequestButton').addEventListener('click', function() {
const name = document.getElementById('postNameInput').value;
const age = document.getElementById('postAgeInput').value;
const genderMale = document.getElementById('genderMale').checked;
const genderFemale = document.getElementById('genderFemale').checked;
if (name.trim() === "" || age.trim() === "" || (!genderMale && !genderFemale)) {
alert("Please fill out all fields.");
return;
}
const gender = genderMale ? "Male" : "Female";
// .cjax (defined in /script/utils.js) is used to make POST request with CSRF token support
// alternatively you can use $.ajax with CSRF token in headers
// the header is named "X-CSRF-Token" and the value is taken from the head
// meta tag content (i.e. <meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">)
$.cjax({
url: './api/post',
type: 'POST',
data: { name: name, age: age, gender: gender },
success: function(data) {
console.log('Response:', data);
$('#postResponseMessage').html(data).show();
},
error: function(xhr, status, error) {
console.error('Error:', error);
$('#postResponseMessage').text('An error occurred while processing your request.').show();
}
});
});
</script>
</div>
</div>
</body>
</html>