Merge pull request #753 from AnthonyMichaelTDM/plugin-improvements

feat(plugins): Implement event system w/ POC events
This commit is contained in:
Anthony Rubick
2025-09-06 15:42:31 -05:00
committed by GitHub
24 changed files with 1714 additions and 14 deletions

View File

@@ -10,6 +10,8 @@ import (
"github.com/microcosm-cc/bluemonday"
"imuslab.com/zoraxy/mod/access"
"imuslab.com/zoraxy/mod/eventsystem"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events"
"imuslab.com/zoraxy/mod/utils"
)
@@ -97,6 +99,17 @@ func handleCreateAccessRule(w http.ResponseWriter, r *http.Request) {
return
}
// emit an event for the new access rule creation
eventsystem.Publisher.Emit(
&events.AccessRuleCreatedEvent{
ID: ruleUUID,
Name: ruleName,
Desc: ruleDesc,
BlacklistEnabled: false,
WhitelistEnabled: false,
},
)
utils.SendOK(w)
}
@@ -359,6 +372,11 @@ func handleBlacklistEnable(w http.ResponseWriter, r *http.Request) {
return
}
eventsystem.Publisher.Emit(&events.BlacklistToggledEvent{
RuleID: ruleID,
Enabled: rule.BlacklistEnabled,
})
utils.SendOK(w)
}
}

View File

@@ -60,6 +60,7 @@ func NewAccessController(options *Options) (*Controller, error) {
//Create one
js, _ := json.MarshalIndent(defaultAccessRule, "", " ")
os.WriteFile(defaultRuleSettingFile, js, 0775)
}
//Generate a controller object
@@ -191,6 +192,7 @@ func (c *Controller) AddNewAccessRule(newRule *AccessRule) error {
//Save rule to file
newRule.SaveChanges()
return nil
}

View File

@@ -1,6 +1,7 @@
package access
import (
"fmt"
"strings"
"imuslab.com/zoraxy/mod/netutils"
@@ -92,3 +93,42 @@ func (s *AccessRule) IsIPBlacklisted(ipAddr string) bool {
return false
}
// GetBlacklistedIPComment returns the comment for a blacklisted IP address
// Searches blacklist for a Country (if country-code provided), IP address, or CIDR that matches the IP address
// returns error if not found
func (s *AccessRule) GetBlacklistedIPComment(ipAddr string) (string, error) {
if countryInfo, err := s.parent.Options.GeoDB.ResolveCountryCodeFromIP(ipAddr); err == nil {
CCBlacklist := *s.BlackListContryCode
countryCode := strings.ToLower(countryInfo.CountryIsoCode)
if comment, ok := CCBlacklist[countryCode]; ok {
return comment, nil
}
}
IPBlacklist := *s.BlackListIP
if comment, ok := IPBlacklist[ipAddr]; ok {
return comment, nil
}
//Check for CIDR
for ipOrCIDR, comment := range IPBlacklist {
wildcardMatch := netutils.MatchIpWildcard(ipAddr, ipOrCIDR)
if wildcardMatch {
return comment, nil
}
cidrMatch := netutils.MatchIpCIDR(ipAddr, ipOrCIDR)
if cidrMatch {
return comment, nil
}
}
return "", fmt.Errorf("IP %s not found in blacklist", ipAddr)
}
// GetParent returns the parent controller
func (s *AccessRule) GetParent() *Controller {
return s.parent
}

View File

@@ -6,7 +6,9 @@ import (
"path/filepath"
"imuslab.com/zoraxy/mod/access"
"imuslab.com/zoraxy/mod/eventsystem"
"imuslab.com/zoraxy/mod/netutils"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events"
)
// Handle access check (blacklist / whitelist), return true if request is handled (aka blocked)
@@ -43,6 +45,23 @@ func accessRequestBlocked(accessRule *access.AccessRule, templateDirectory strin
w.Write(template)
}
// Emit blacklisted IP blocked event
// Get the comment for this IP
comment, err := accessRule.GetBlacklistedIPComment(clientIpAddr)
if err != nil {
comment = "blacklisted"
}
eventsystem.Publisher.Emit(
&events.BlacklistedIPBlockedEvent{
IP: clientIpAddr,
Comment: comment,
RequestedURL: r.URL.String(),
Hostname: r.Host,
UserAgent: r.Header.Get("User-Agent"),
Method: r.Method,
},
)
return true, "blacklist"
}

View File

@@ -0,0 +1,125 @@
package eventsystem
import (
"sync"
"time"
"imuslab.com/zoraxy/mod/info/logger"
// "imuslab.com/zoraxy/mod/plugins"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events"
)
type ListenerID string
type Listener interface {
Notify(event events.Event) error
GetID() ListenerID
}
// eventManager manages event subscriptions and dispatching events to listeners
type eventManager struct {
subscriptions map[events.EventName][]ListenerID // EventType -> []Subscriber, tracks which events each listener is subscribed to
subscribers map[ListenerID]Listener // ListenerID -> Listener, tracks all registered listeners
logger *logger.Logger // Logger for the event manager
mutex sync.RWMutex // Mutex for concurrent access
}
var (
// Publisher is the singleton instance of the event manager
Publisher *eventManager
once sync.Once
)
// InitEventSystem initializes the event manager with the plugin manager
func InitEventSystem(logger *logger.Logger) {
once.Do(func() {
Publisher = &eventManager{
subscriptions: make(map[events.EventName][]ListenerID),
subscribers: make(map[ListenerID]Listener),
logger: logger,
}
})
}
// RegisterSubscriberToEvent adds a listener to the subscription list for an event type
func (em *eventManager) RegisterSubscriberToEvent(subscriber Listener, eventType events.EventName) error {
em.mutex.Lock()
defer em.mutex.Unlock()
if _, exists := em.subscriptions[eventType]; !exists {
em.subscriptions[eventType] = []ListenerID{}
}
// Register the listener if not already registered
listenerID := subscriber.GetID()
em.subscribers[listenerID] = subscriber
// Check if already subscribed to the event
for _, id := range em.subscriptions[eventType] {
if id == listenerID {
return nil // Already subscribed
}
}
// Register the listener to the event
em.subscriptions[eventType] = append(em.subscriptions[eventType], listenerID)
return nil
}
// Deregister removes a listener from all event subscriptions, and
// also removes the listener from the list of subscribers.
func (em *eventManager) UnregisterSubscriber(listenerID ListenerID) error {
em.mutex.Lock()
defer em.mutex.Unlock()
for eventType, subscribers := range em.subscriptions {
for i, id := range subscribers {
if id == listenerID {
em.subscriptions[eventType] = append(subscribers[:i], subscribers[i+1:]...)
break
}
}
}
delete(em.subscribers, listenerID)
return nil
}
// Emit dispatches an event to all subscribed listeners
func (em *eventManager) Emit(payload events.EventPayload) error {
eventName := payload.GetName()
em.mutex.RLock()
defer em.mutex.RUnlock()
subscribers, exists := em.subscriptions[eventName]
if !exists || len(subscribers) == 0 {
return nil // No subscribers
}
// Create the event
event := events.Event{
Name: eventName,
Timestamp: time.Now().Unix(),
Data: payload,
}
// Dispatch to all subscribers asynchronously
for _, listenerID := range subscribers {
listener, exists := em.subscribers[listenerID]
if !exists {
em.logger.PrintAndLog("event-system", "Failed to get listener for event dispatch, removing "+string(listenerID)+" from subscriptions", nil)
continue
}
go func(l Listener) {
if err := l.Notify(event); err != nil {
em.logger.PrintAndLog("event-system", "Failed to dispatch `"+string(event.Name)+"` event to listener "+string(listenerID), err)
}
}(listener)
}
return nil
}

View File

@@ -0,0 +1,113 @@
package eventsystem
import (
"encoding/json"
"fmt"
"testing"
"time"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events"
)
// Test (de)serialization of events
func TestEventDeSerialization(t *testing.T) {
type SerializationTest struct {
name string
event events.Event
expectedJson string
}
timestamp := time.Now().Unix()
tests := []SerializationTest{
{
name: "BlacklistedIPBlocked",
event: events.Event{
Name: events.EventBlacklistedIPBlocked,
Timestamp: timestamp,
Data: &events.BlacklistedIPBlockedEvent{
IP: "192.168.1.1",
Comment: "Test comment",
RequestedURL: "http://example.com",
Hostname: "example.com",
UserAgent: "TestUserAgent",
Method: "GET",
},
},
expectedJson: `{"name":"blacklistedIpBlocked","timestamp":` + fmt.Sprintf("%d", timestamp) + `,"data":{"ip":"192.168.1.1","comment":"Test comment","requested_url":"http://example.com","hostname":"example.com","user_agent":"TestUserAgent","method":"GET"}}`,
},
{
name: "BlacklistToggled",
event: events.Event{
Name: events.EventBlacklistToggled,
Timestamp: timestamp,
Data: &events.BlacklistToggledEvent{
RuleID: "rule123",
Enabled: true,
},
},
expectedJson: `{"name":"blacklistToggled","timestamp":` + fmt.Sprintf("%d", timestamp) + `,"data":{"rule_id":"rule123","enabled":true}}`,
},
{
name: "AccessRuleCreated",
event: events.Event{
Name: events.EventAccessRuleCreated,
Timestamp: timestamp,
Data: &events.AccessRuleCreatedEvent{
ID: "rule456",
Name: "New Access Rule",
Desc: "A dummy access rule",
BlacklistEnabled: true,
WhitelistEnabled: false,
},
},
expectedJson: `{"name":"accessRuleCreated","timestamp":` + fmt.Sprintf("%d", timestamp) + `,"data":{"id":"rule456","name":"New Access Rule","desc":"A dummy access rule","blacklist_enabled":true,"whitelist_enabled":false}}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Serialize the event
jsonData, err := json.Marshal(test.event)
if err != nil {
t.Fatalf("Failed to serialize event: %v", err)
}
// Compare the serialized JSON with the expected JSON
if string(jsonData) != test.expectedJson {
t.Fatalf("Unexpected JSON output.\nGot: %s\nWant: %s", jsonData, test.expectedJson)
}
// Deserialize the JSON back into an event
var deserializedEvent events.Event
if err := events.ParseEvent(jsonData, &deserializedEvent); err != nil {
t.Fatalf("Failed to parse event: %v", err)
}
// Compare the original event with the deserialized event
if deserializedEvent.Name != test.event.Name || deserializedEvent.Timestamp != test.event.Timestamp {
t.Fatalf("Deserialized event does not match original.\nGot: %+v\nWant: %+v", deserializedEvent, test.event)
}
switch data := deserializedEvent.Data.(type) {
case *events.BlacklistedIPBlockedEvent:
originalData, ok := test.event.Data.(*events.BlacklistedIPBlockedEvent)
if !ok || *data != *originalData {
t.Fatalf("Deserialized BlacklistedIPBlockedEvent does not match original.\nGot: %+v\nWant: %+v", data, originalData)
}
case *events.AccessRuleCreatedEvent:
originalData, ok := test.event.Data.(*events.AccessRuleCreatedEvent)
if !ok || *data != *originalData {
t.Fatalf("Deserialized AccessRuleCreatedEvent does not match original.\nGot: %+v\nWant: %+v", data, originalData)
}
case *events.BlacklistToggledEvent:
originalData, ok := test.event.Data.(*events.BlacklistToggledEvent)
if !ok || *data != *originalData {
t.Fatalf("Deserialized BlacklistToggledEvent does not match original.\nGot: %+v\nWant: %+v", data, originalData)
}
default:
t.Fatalf("Unknown event type: %T", data)
}
})
}
}

View File

@@ -0,0 +1,83 @@
// implements `eventsystem.Listener` for Plugin
package plugins
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"imuslab.com/zoraxy/mod/eventsystem"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events"
)
func (p *Plugin) GetID() eventsystem.ListenerID {
return eventsystem.ListenerID(p.Spec.ID)
}
// Send an event to the plugin
func (p *Plugin) Notify(event events.Event) error {
// Handle the event notification
if !p.Enabled || p.AssignedPort == 0 {
return fmt.Errorf("plugin %s is not running", p.Spec.ID)
}
subscriptionPath := p.Spec.SubscriptionPath
if subscriptionPath == "" {
return fmt.Errorf("plugin %s has no subscription path configured", p.Spec.ID)
}
if !strings.HasPrefix(subscriptionPath, "/") {
subscriptionPath = "/" + subscriptionPath
}
// Prepare the URL
url := fmt.Sprintf("http://127.0.0.1:%d%s/%s", p.AssignedPort, subscriptionPath, event.Name)
// Marshal the event to JSON
eventData, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}
// Create HTTP request
req, err := http.NewRequest("POST", url, bytes.NewBuffer(eventData))
if err != nil {
return fmt.Errorf("failed to create HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Zoraxy-Event-Type", string(event.Name))
// Send the request with timeout
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return errors.New("Failed to send event: " + err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody := fmt.Errorf("no response body")
if resp.ContentLength > 0 {
buffer := bytes.NewBuffer(make([]byte, 0, resp.ContentLength))
_, respErr := buffer.ReadFrom(resp.Body)
if respErr != nil {
respBody = fmt.Errorf("failed to read response body: %v", respErr)
} else {
respBody = fmt.Errorf("response body: %s", buffer.String())
}
}
return fmt.Errorf("plugin %s returned non-200 status for event `%s` (%s): %w", p.Spec.ID, event.Name, resp.Status, respBody)
}
return nil
}

View File

@@ -14,7 +14,9 @@ import (
"time"
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
"imuslab.com/zoraxy/mod/eventsystem"
zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events"
)
func (m *Manager) StartPlugin(pluginID string) error {
@@ -151,6 +153,19 @@ func (m *Manager) StartPlugin(pluginID string) error {
thisPlugin.process = cmd
thisPlugin.Enabled = true
// Register event subscriptions
if thisPlugin.Spec.SubscriptionsEvents != nil {
for eventName := range thisPlugin.Spec.SubscriptionsEvents {
eventType := events.EventName(eventName)
err := eventsystem.Publisher.RegisterSubscriberToEvent(thisPlugin, eventType)
if err != nil {
m.Log("Failed to subscribe plugin "+thisPlugin.Spec.Name+" to event "+string(eventName), err)
} else {
m.Log("Subscribed plugin "+thisPlugin.Spec.Name+" to event "+string(eventName), nil)
}
}
}
//Create a new static forwarder router for each of the static capture paths
thisPlugin.StartAllStaticPathRouters()
@@ -288,6 +303,11 @@ func (m *Manager) StopPlugin(pluginID string) error {
if err != nil {
m.Log("Failed to revoke API keys for plugin "+thisPlugin.Spec.Name, err)
}
//Unsubscribe from all events
err = eventsystem.Publisher.UnregisterSubscriber(eventsystem.ListenerID(thisPlugin.Spec.ID))
if err != nil {
m.Log("Failed to unsubscribe plugin "+thisPlugin.Spec.Name+" from events", err)
}
return nil
}

View File

@@ -60,7 +60,7 @@ type Manager struct {
LoadedPlugins map[string]*Plugin //Storing *Plugin
tagPluginMap sync.Map //Storing *radix.Tree for each plugin tag
tagPluginListMutex sync.RWMutex //Mutex for the tagPluginList
tagPluginList map[string][]*Plugin //Storing the plugin list for each tag, only concurrent READ is allowed
tagPluginList map[string][]*Plugin //Storing the plugin list for each tag, only concurrent read is allowed
Options *ManagerOptions
PluginHash map[string]string //The hash of the plugin file, used to check if the plugin file is changed

View File

@@ -0,0 +1,120 @@
package events
import (
"encoding/json"
"fmt"
)
// EventName represents the type of event
type EventName string
// EventPayload interface for all event payloads
type EventPayload interface {
// GetName returns the event type
GetName() EventName
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
Data EventPayload `json:"data"`
}
const (
// EventBlacklistedIPBlocked is emitted when a blacklisted IP is blocked
EventBlacklistedIPBlocked EventName = "blacklistedIpBlocked"
// EventBlacklistToggled is emitted when the blacklist is toggled for an access rule
EventBlacklistToggled EventName = "blacklistToggled"
// EventAccessRuleCreated is emitted when a new access ruleset is created
EventAccessRuleCreated EventName = "accessRuleCreated"
// Add more event types as needed
)
// BlacklistedIPBlockedEvent represents an event when a blacklisted IP is blocked
type BlacklistedIPBlockedEvent struct {
IP string `json:"ip"`
Comment string `json:"comment"`
RequestedURL string `json:"requested_url"`
Hostname string `json:"hostname"`
UserAgent string `json:"user_agent"`
Method string `json:"method"`
}
func (e *BlacklistedIPBlockedEvent) GetName() EventName {
return EventBlacklistedIPBlocked
}
// BlacklistToggledEvent represents an event when the blacklist is disabled for an access rule
type BlacklistToggledEvent struct {
RuleID string `json:"rule_id"`
Enabled bool `json:"enabled"` // Whether the blacklist is enabled or disabled
}
func (e *BlacklistToggledEvent) GetName() EventName {
return EventBlacklistToggled
}
// AccessRuleCreatedEvent represents an event when a new access ruleset is created
type AccessRuleCreatedEvent struct {
ID string `json:"id"`
Name string `json:"name"`
Desc string `json:"desc"`
BlacklistEnabled bool `json:"blacklist_enabled"`
WhitelistEnabled bool `json:"whitelist_enabled"`
}
func (e *AccessRuleCreatedEvent) GetName() EventName {
return EventAccessRuleCreated
}
// ParseEvent parses a JSON byte slice into an Event struct
func ParseEvent(jsonData []byte, event *Event) error {
// First, determine the event type, and parse shared fields, from the JSON data
var temp struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
// Now, based on the event type, unmarshal the specific payload
switch temp.Name {
case EventBlacklistedIPBlocked:
type tempData struct {
Data BlacklistedIPBlockedEvent `json:"data"`
}
var payload tempData
if err := json.Unmarshal(jsonData, &payload); err != nil {
return err
}
event.Data = &payload.Data
case EventBlacklistToggled:
type tempData struct {
Data BlacklistToggledEvent `json:"data"`
}
var payload tempData
if err := json.Unmarshal(jsonData, &payload); err != nil {
return err
}
event.Data = &payload.Data
case EventAccessRuleCreated:
type tempData struct {
Data AccessRuleCreatedEvent `json:"data"`
}
var payload tempData
if err := json.Unmarshal(jsonData, &payload); err != nil {
return err
}
event.Data = &payload.Data
default:
return fmt.Errorf("unknown event: %s, %v", temp.Name, jsonData)
}
return nil
}

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"os"
"strings"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events"
)
/*
@@ -102,8 +104,8 @@ type IntroSpect struct {
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, paired with comments describing how the event is used, see Zoraxy documentation for more details
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[events.EventName]string `json:"subscriptions_events"` //Subscriptions events of your plugin, paired with comments describing how the event is used, see Zoraxy documentation for more details
/* API Access Control */
PermittedAPIEndpoints []PermittedAPIEndpoint `json:"permitted_api_endpoints"` //List of API endpoints this plugin can access, and a description of why the plugin needs to access this endpoint
@@ -167,12 +169,12 @@ func RecvConfigureSpec() (*ConfigureSpec, error) {
return nil, err
}
} else {
return nil, fmt.Errorf("No port specified after -configure flag")
return nil, fmt.Errorf("no port specified after -configure flag")
}
return &configSpec, nil
}
}
return nil, fmt.Errorf("No -configure flag found")
return nil, fmt.Errorf("no -configure flag found")
}
/*

View File

@@ -11,6 +11,7 @@ import (
"time"
"imuslab.com/zoraxy/mod/auth/sso/oauth2"
"imuslab.com/zoraxy/mod/eventsystem"
"github.com/gorilla/csrf"
"imuslab.com/zoraxy/mod/access"
@@ -369,6 +370,11 @@ func startupSequence() {
HotReloadInterval: 5, //seconds
})
/*
Event Manager
*/
eventsystem.InitEventSystem(SystemWideLogger)
//Sync latest plugin list from the plugin store
go func() {
err = pluginManager.UpdateDownloadablePluginList()

View File

@@ -136,6 +136,11 @@
<small>The relative path of the web UI</small></td>
<td id="registered_ui_proxy_path"></td>
</tr>
<tr>
<td>Registered Subscribed Event Path</td>
<small>Path where subscribed events are sent</small>
<td id="registered_subscription_path">Not registered</td>
</tr>
</tbody>
</table>
<div class="ui divider"></div>
@@ -159,6 +164,23 @@
API keys are generated automatically by Zoraxy when a plugin with permitted API endpoints is enabled.
</p>
<div class="ui divider"></div>
<h4>Plugin IntroSpect Event Subscriptions</h4>
<p>The following events are subscribed to by this plugin and will be sent to the plugin's event subscription path:</p>
<table class="ui basic celled unstackable table">
<thead>
<tr>
<th>Event Type</th>
<th>Comment</th>
</tr>
</thead>
<!-- This tbody will be filled by JavaScript -->
<tbody id="plugin_subscriptions_events">
<tr>
<td colspan="2">No subscribed events</td>
</tr>
</tbody>
</table>
<div class="ui divider"></div>
</div>
</div>
</div>
@@ -234,11 +256,16 @@
if (registeredUIProxyPath == null || registeredUIProxyPath == "") {
registeredUIProxyPath = "No UI registered";
}
let subscriptionPath = data.Spec.subscription_path;
if (subscriptionPath == null || subscriptionPath == "") {
subscriptionPath = "Not registered";
}
$("#static_capture_ingress").text(staticCaptureIngress);
$("#dynamic_capture_sniffing_path").text(dynamicCaptureSniffingPath);
$("#dynamic_capture_ingress").text(dynamicCaptureIngress);
$("#registered_ui_proxy_path").text(registeredUIProxyPath);
$("#registered_subscription_path").text(subscriptionPath);
//Update permitted API endpoints
let apiEndpoints = data.Spec.permitted_api_endpoints;
@@ -255,9 +282,24 @@
});
$("#plugin_permitted_api_endpoints").html(endpointRows);
}
//Update subscribed events if available
let subscriptionsEvents = data.Spec.subscriptions_events; // this is a map of event_type to comment
if (subscriptionsEvents == null || Object.keys(subscriptionsEvents).length == 0) {
$("#plugin_subscriptions_events").html('<tr><td colspan="2">No subscribed events</td></tr>');
} else {
let eventRows = '';
Object.keys(subscriptionsEvents).forEach(function(eventType) {
eventRows += `<tr>
<td>${eventType}</td>
<td>${subscriptionsEvents[eventType] || "No comment"}</td>
</tr>`;
});
$("#plugin_subscriptions_events").html(eventRows);
}
});
}
$(".advanceSettings").accordion();
function closeThisWrapper(){