chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,646 @@
|
||||
local workspaces = require "kong.workspaces"
|
||||
local constants = require "kong.constants"
|
||||
local tablepool = require "tablepool"
|
||||
local req_dyn_hook = require "kong.dynamic_hook"
|
||||
local wasm = require "kong.runloop.wasm"
|
||||
|
||||
|
||||
local kong = kong
|
||||
local error = error
|
||||
local assert = assert
|
||||
local var = ngx.var
|
||||
local null = ngx.null
|
||||
local pcall = pcall
|
||||
local subsystem = ngx.config.subsystem
|
||||
local pairs = pairs
|
||||
local ipairs = ipairs
|
||||
local format = string.format
|
||||
local fetch_table = tablepool.fetch
|
||||
local release_table = tablepool.release
|
||||
local uuid = require("kong.tools.uuid").uuid
|
||||
local get_updated_monotonic_ms = require("kong.tools.time").get_updated_monotonic_ms
|
||||
local req_dyn_hook_disable_by_default = req_dyn_hook.disable_by_default
|
||||
|
||||
|
||||
local TTL_ZERO = { ttl = 0 }
|
||||
local GLOBAL_QUERY_OPTS = { workspace = null, show_ws_id = true }
|
||||
|
||||
|
||||
local NON_COLLECTING_PHASES, DOWNSTREAM_PHASES, DOWNSTREAM_PHASES_COUNT, COLLECTING_PHASE, CONFIGURE_PHASE
|
||||
do
|
||||
if subsystem == "stream" then
|
||||
NON_COLLECTING_PHASES = {
|
||||
"certificate",
|
||||
"log",
|
||||
}
|
||||
|
||||
DOWNSTREAM_PHASES = {
|
||||
"log",
|
||||
}
|
||||
|
||||
COLLECTING_PHASE = "preread"
|
||||
|
||||
else
|
||||
NON_COLLECTING_PHASES = {
|
||||
"certificate",
|
||||
"rewrite",
|
||||
"response",
|
||||
"header_filter",
|
||||
"body_filter",
|
||||
"log",
|
||||
}
|
||||
|
||||
DOWNSTREAM_PHASES = {
|
||||
"response",
|
||||
"header_filter",
|
||||
"body_filter",
|
||||
"log",
|
||||
}
|
||||
|
||||
COLLECTING_PHASE = "access"
|
||||
end
|
||||
|
||||
DOWNSTREAM_PHASES_COUNT = #DOWNSTREAM_PHASES
|
||||
CONFIGURE_PHASE = "configure"
|
||||
end
|
||||
|
||||
|
||||
local PLUGINS_NS = "plugins." .. subsystem
|
||||
local ENABLED_PLUGINS
|
||||
local LOADED_PLUGINS
|
||||
local CONFIGURABLE_PLUGINS
|
||||
|
||||
|
||||
local PluginsIterator = {}
|
||||
|
||||
|
||||
---
|
||||
-- Build a compound key by string formatting route_id, service_id, and consumer_id with colons as separators.
|
||||
--
|
||||
-- @function build_compound_key
|
||||
-- @tparam string|nil route_id The route identifier. If `nil`, an empty string is used.
|
||||
-- @tparam string|nil service_id The service identifier. If `nil`, an empty string is used.
|
||||
-- @tparam string|nil consumer_id The consumer identifier. If `nil`, an empty string is used.
|
||||
-- @treturn string The compound key, in the format `route_id:service_id:consumer_id`.
|
||||
local function build_compound_key(route_id, service_id, consumer_id)
|
||||
return format("%s:%s:%s", route_id or "", service_id or "", consumer_id or "")
|
||||
end
|
||||
|
||||
|
||||
local PLUGIN_GLOBAL_KEY = build_compound_key() -- all nil
|
||||
|
||||
|
||||
local function get_table_for_ctx(ws)
|
||||
local tbl = fetch_table(PLUGINS_NS, 0, DOWNSTREAM_PHASES_COUNT + 2)
|
||||
if not tbl.initialized then
|
||||
for i = 1, DOWNSTREAM_PHASES_COUNT do
|
||||
tbl[DOWNSTREAM_PHASES[i]] = kong.table.new(ws.plugins[0] * 2, 1)
|
||||
end
|
||||
tbl.initialized = true
|
||||
end
|
||||
|
||||
for i = 1, DOWNSTREAM_PHASES_COUNT do
|
||||
tbl[DOWNSTREAM_PHASES[i]][0] = 0
|
||||
end
|
||||
|
||||
tbl.ws = ws
|
||||
|
||||
return tbl
|
||||
end
|
||||
|
||||
|
||||
local function release(ctx)
|
||||
local plugins = ctx.plugins
|
||||
if plugins then
|
||||
release_table(PLUGINS_NS, plugins, true)
|
||||
ctx.plugins = nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function get_loaded_plugins()
|
||||
return assert(kong.db.plugins:get_handlers())
|
||||
end
|
||||
|
||||
|
||||
local function get_configurable_plugins()
|
||||
local i = 0
|
||||
local plugins_with_configure_phase = {}
|
||||
for _, plugin in ipairs(LOADED_PLUGINS) do
|
||||
if plugin.handler[CONFIGURE_PHASE] then
|
||||
i = i + 1
|
||||
local name = plugin.name
|
||||
plugins_with_configure_phase[name] = true
|
||||
plugins_with_configure_phase[i] = plugin
|
||||
end
|
||||
end
|
||||
return plugins_with_configure_phase
|
||||
end
|
||||
|
||||
|
||||
local function should_process_plugin(plugin)
|
||||
if wasm.filters_by_name[plugin.name] then
|
||||
return false
|
||||
end
|
||||
|
||||
if plugin.enabled then
|
||||
local c = constants.PROTOCOLS_WITH_SUBSYSTEM
|
||||
for _, protocol in ipairs(plugin.protocols) do
|
||||
if c[protocol] == subsystem then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function get_plugin_config(plugin, name, ws_id)
|
||||
if not plugin or not plugin.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local cfg = plugin.config or {}
|
||||
|
||||
cfg.route_id = plugin.route and plugin.route.id
|
||||
cfg.service_id = plugin.service and plugin.service.id
|
||||
cfg.consumer_id = plugin.consumer and plugin.consumer.id
|
||||
cfg.plugin_instance_name = plugin.instance_name
|
||||
cfg.__plugin_id = plugin.id
|
||||
cfg.__ws_id = ws_id
|
||||
|
||||
local key = kong.db.plugins:cache_key(name,
|
||||
cfg.route_id,
|
||||
cfg.service_id,
|
||||
cfg.consumer_id,
|
||||
nil,
|
||||
ws_id)
|
||||
|
||||
-- TODO: deprecate usage of __key__ as id of plugin
|
||||
if not cfg.__key__ then
|
||||
cfg.__key__ = key
|
||||
-- generate a unique sequence across workers
|
||||
-- with a seq 0, plugin server generates an unused random instance id
|
||||
local next_seq, err = ngx.shared.kong:incr("plugins_iterator:__seq__", 1, 0, 0)
|
||||
if err then
|
||||
next_seq = 0
|
||||
end
|
||||
cfg.__seq__ = next_seq
|
||||
end
|
||||
|
||||
return cfg
|
||||
end
|
||||
|
||||
|
||||
---
|
||||
-- Lookup a configuration for a given combination of route_id, service_id, consumer_id
|
||||
--
|
||||
-- The function checks various combinations of route_id, service_id and consumer_id to find
|
||||
-- the best matching configuration in the given 'combos' table. The priority order is as follows:
|
||||
--
|
||||
-- 1. Route, Service, Consumer
|
||||
-- 2. Route, Consumer
|
||||
-- 3. Service, Consumer
|
||||
-- 4. Route, Service
|
||||
-- 5. Consumer
|
||||
-- 6. Route
|
||||
-- 7. Service
|
||||
-- 8. Global
|
||||
--
|
||||
-- @function lookup_cfg
|
||||
-- @tparam table combos A table containing configuration data indexed by compound keys.
|
||||
-- @tparam string|nil route_id The route identifier.
|
||||
-- @tparam string|nil service_id The service identifier.
|
||||
-- @tparam string|nil consumer_id The consumer identifier.
|
||||
-- @return any|nil The configuration corresponding to the best matching combination, or 'nil' if no configuration is found.
|
||||
local function lookup_cfg(combos, route_id, service_id, consumer_id)
|
||||
-- Use the build_compound_key function to create an index for the 'combos' table
|
||||
if route_id and service_id and consumer_id then
|
||||
local key = build_compound_key(route_id, service_id, consumer_id)
|
||||
if combos[key] then
|
||||
return combos[key]
|
||||
end
|
||||
end
|
||||
|
||||
if route_id and consumer_id then
|
||||
local key = build_compound_key(route_id, nil, consumer_id)
|
||||
if combos[key] then
|
||||
return combos[key]
|
||||
end
|
||||
end
|
||||
|
||||
if service_id and consumer_id then
|
||||
local key = build_compound_key(nil, service_id, consumer_id)
|
||||
if combos[key] then
|
||||
return combos[key]
|
||||
end
|
||||
end
|
||||
|
||||
if route_id and service_id then
|
||||
local key = build_compound_key(route_id, service_id, nil)
|
||||
if combos[key] then
|
||||
return combos[key]
|
||||
end
|
||||
end
|
||||
|
||||
if consumer_id then
|
||||
local key = build_compound_key(nil, nil, consumer_id)
|
||||
if combos[key] then
|
||||
return combos[key]
|
||||
end
|
||||
end
|
||||
|
||||
if route_id then
|
||||
local key = build_compound_key(route_id, nil, nil)
|
||||
if combos[key] then
|
||||
return combos[key]
|
||||
end
|
||||
end
|
||||
|
||||
if service_id then
|
||||
local key = build_compound_key(nil, service_id, nil)
|
||||
if combos[key] then
|
||||
return combos[key]
|
||||
end
|
||||
end
|
||||
|
||||
return combos[PLUGIN_GLOBAL_KEY]
|
||||
end
|
||||
|
||||
|
||||
---
|
||||
-- Load the plugin configuration based on the context (route, service, and consumer) and plugin handler rules.
|
||||
--
|
||||
-- This function filters out route, service, and consumer information from the context based on the plugin handler rules,
|
||||
-- and then calls the 'lookup_cfg' function to get the best matching plugin configuration for the given combination of
|
||||
-- route_id, service_id, and consumer_id.
|
||||
--
|
||||
-- @function load_configuration_through_combos
|
||||
-- @tparam table ctx A table containing the context information, including route, service, and authenticated_consumer.
|
||||
-- @tparam table combos A table containing configuration data indexed by compound keys.
|
||||
-- @tparam table plugin A table containing plugin information, including the handler with no_route, no_service, and no_consumer rules.
|
||||
-- @treturn any|nil The configuration corresponding to the best matching combination, or 'nil' if no configuration is found.
|
||||
local function load_configuration_through_combos(ctx, combos, plugin)
|
||||
-- Filter out route, service, and consumer based on the plugin handler rules and get their ids
|
||||
local handler = plugin.handler
|
||||
local route_id = (not handler.no_route and ctx.route) and ctx.route.id or nil
|
||||
local service_id = (not handler.no_service and ctx.service) and ctx.service.id or nil
|
||||
local consumer_id = (not handler.no_consumer and ctx.authenticated_consumer) and ctx.authenticated_consumer.id or nil
|
||||
|
||||
-- Call the lookup_cfg function to get the best matching plugin configuration
|
||||
return lookup_cfg(combos, route_id, service_id, consumer_id)
|
||||
end
|
||||
|
||||
|
||||
local function get_workspace(self, ctx)
|
||||
if not ctx then
|
||||
return self.ws[kong.default_workspace]
|
||||
end
|
||||
|
||||
return self.ws[workspaces.get_workspace_id(ctx) or kong.default_workspace]
|
||||
end
|
||||
|
||||
|
||||
local function get_next_init_worker(plugins, i)
|
||||
local i = i + 1
|
||||
local plugin = plugins[i]
|
||||
if not plugin then
|
||||
return nil
|
||||
end
|
||||
|
||||
if plugin.handler.init_worker then
|
||||
return i, plugin
|
||||
end
|
||||
|
||||
return get_next_init_worker(plugins, i)
|
||||
end
|
||||
|
||||
|
||||
local function get_init_worker_iterator(self)
|
||||
if #self.loaded == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
return get_next_init_worker, self.loaded
|
||||
end
|
||||
|
||||
|
||||
local function get_next_global_or_collected_plugin(plugins, i)
|
||||
i = i + 2
|
||||
if i > plugins[0] then
|
||||
return nil
|
||||
end
|
||||
|
||||
return i, plugins[i - 1], plugins[i]
|
||||
end
|
||||
|
||||
|
||||
local function get_global_iterator(self, phase)
|
||||
local plugins = self.globals[phase]
|
||||
local count = plugins and plugins[0] or 0
|
||||
if count == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- only execute this once per request
|
||||
if phase == "certificate" or (phase == "rewrite" and var.https ~= "on") then
|
||||
local i = 2
|
||||
while i <= count do
|
||||
kong.vault.update(plugins[i])
|
||||
i = i + 2
|
||||
end
|
||||
end
|
||||
|
||||
return get_next_global_or_collected_plugin, plugins
|
||||
end
|
||||
|
||||
|
||||
local function get_collected_iterator(self, phase, ctx)
|
||||
local plugins = ctx.plugins
|
||||
if plugins then
|
||||
plugins = plugins[phase]
|
||||
if not plugins or plugins[0] == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
return get_next_global_or_collected_plugin, plugins
|
||||
end
|
||||
|
||||
return get_global_iterator(self, phase)
|
||||
end
|
||||
|
||||
|
||||
local function get_next_and_collect(ctx, i)
|
||||
i = i + 1
|
||||
local ws = ctx.plugins.ws
|
||||
local plugins = ws.plugins
|
||||
if i > plugins[0] then
|
||||
return nil
|
||||
end
|
||||
|
||||
local plugin = plugins[i]
|
||||
local name = plugin.name
|
||||
local cfg
|
||||
-- Only pass combos for the plugin we're operating on
|
||||
local combos = ws.combos[name]
|
||||
if combos then
|
||||
cfg = load_configuration_through_combos(ctx, combos, plugin)
|
||||
if cfg then
|
||||
kong.vault.update(cfg)
|
||||
local handler = plugin.handler
|
||||
local collected = ctx.plugins
|
||||
for j = 1, DOWNSTREAM_PHASES_COUNT do
|
||||
local phase = DOWNSTREAM_PHASES[j]
|
||||
if handler[phase] then
|
||||
local n = collected[phase][0] + 2
|
||||
collected[phase][0] = n
|
||||
collected[phase][n] = cfg
|
||||
collected[phase][n - 1] = plugin
|
||||
if phase == "response" and not ctx.buffered_proxying then
|
||||
ctx.buffered_proxying = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if handler[COLLECTING_PHASE] then
|
||||
return i, plugin, cfg
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return get_next_and_collect(ctx, i)
|
||||
end
|
||||
|
||||
|
||||
local function get_collecting_iterator(self, ctx)
|
||||
local ws = get_workspace(self, ctx)
|
||||
ctx.plugins = get_table_for_ctx(ws)
|
||||
if not ws then
|
||||
return nil
|
||||
end
|
||||
|
||||
local plugins = ws.plugins
|
||||
if plugins[0] == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
return get_next_and_collect, ctx
|
||||
end
|
||||
|
||||
|
||||
local function new_ws_data()
|
||||
return {
|
||||
plugins = { [0] = 0 },
|
||||
combos = {},
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
local function configure(configurable, ctx)
|
||||
-- Disable hooks that are selectively enabled by plugins
|
||||
-- in their :configure handler
|
||||
req_dyn_hook_disable_by_default("observability_logs")
|
||||
|
||||
ctx = ctx or ngx.ctx
|
||||
local kong_global = require "kong.global"
|
||||
for _, plugin in ipairs(CONFIGURABLE_PLUGINS) do
|
||||
local name = plugin.name
|
||||
|
||||
kong_global.set_namespaced_log(kong, plugin.name, ctx)
|
||||
local start = get_updated_monotonic_ms()
|
||||
local ok, err = pcall(plugin.handler[CONFIGURE_PHASE], plugin.handler, configurable[name])
|
||||
local elapsed = get_updated_monotonic_ms() - start
|
||||
kong_global.reset_log(kong, ctx)
|
||||
|
||||
if not ok then
|
||||
kong.log.err("failed to execute plugin '", name, ":", CONFIGURE_PHASE, " (", err, ")")
|
||||
else
|
||||
if elapsed > 50 then
|
||||
kong.log.notice("executing plugin '", name, ":", CONFIGURE_PHASE, " took excessively long: ", elapsed, " ms")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function create_configure(configurable)
|
||||
-- we only want the plugin_iterator:configure to be only available on proxying
|
||||
-- nodes (or data planes), thus we disable it if this code gets executed on control
|
||||
-- plane or on a node that does not listen any proxy ports.
|
||||
--
|
||||
-- TODO: move to PDK, e.g. kong.node.is_proxying()
|
||||
if kong.configuration.role == "control_plane"
|
||||
or ((subsystem == "http" and #kong.configuration.proxy_listeners == 0) or
|
||||
(subsystem == "stream" and #kong.configuration.stream_listeners == 0))
|
||||
then
|
||||
return function() end
|
||||
end
|
||||
|
||||
return function(self, ctx)
|
||||
configure(configurable, ctx)
|
||||
-- self destruct the function so that it cannot be called twice
|
||||
-- if it ever happens to be called twice, it should be very visible
|
||||
-- because of this.
|
||||
self.configure = nil
|
||||
configurable = nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function PluginsIterator.new(version)
|
||||
local is_not_dbless = kong.db.strategy ~= "off"
|
||||
if is_not_dbless then
|
||||
if not version then
|
||||
error("version must be given", 2)
|
||||
end
|
||||
end
|
||||
|
||||
LOADED_PLUGINS = LOADED_PLUGINS or get_loaded_plugins()
|
||||
CONFIGURABLE_PLUGINS = CONFIGURABLE_PLUGINS or get_configurable_plugins()
|
||||
ENABLED_PLUGINS = ENABLED_PLUGINS or kong.configuration.loaded_plugins
|
||||
|
||||
local ws_id = workspaces.get_workspace_id() or kong.default_workspace
|
||||
local ws = {
|
||||
[ws_id] = new_ws_data()
|
||||
}
|
||||
|
||||
local counter = 0
|
||||
local globals
|
||||
do
|
||||
globals = {}
|
||||
for _, phase in ipairs(NON_COLLECTING_PHASES) do
|
||||
globals[phase] = { [0] = 0 }
|
||||
end
|
||||
end
|
||||
|
||||
local configurable = {}
|
||||
local has_plugins = false
|
||||
local page_size = kong.db.plugins.pagination.max_page_size
|
||||
|
||||
if kong.configuration.role == "control_plane" then
|
||||
goto done
|
||||
end
|
||||
|
||||
for plugin, err in kong.db.plugins:each(page_size, GLOBAL_QUERY_OPTS) do
|
||||
if err then
|
||||
return nil, err
|
||||
end
|
||||
|
||||
local name = plugin.name
|
||||
if not ENABLED_PLUGINS[name] then
|
||||
return nil, name .. " plugin is in use but not enabled"
|
||||
end
|
||||
|
||||
if is_not_dbless and counter > 0 and counter % page_size == 0 and kong.core_cache then
|
||||
local new_version, err = kong.core_cache:get("plugins_iterator:version", TTL_ZERO, uuid)
|
||||
if err then
|
||||
return nil, "failed to retrieve plugins iterator version: " .. err
|
||||
end
|
||||
|
||||
if new_version ~= version then
|
||||
-- the plugins iterator rebuild is being done by a different process at
|
||||
-- the same time, stop here and let the other one go for it
|
||||
kong.log.info("plugins iterator was changed while rebuilding it")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if should_process_plugin(plugin) then
|
||||
-- Get the plugin configuration for the specified workspace (ws_id)
|
||||
local cfg = get_plugin_config(plugin, name, plugin.ws_id)
|
||||
if cfg then
|
||||
has_plugins = true
|
||||
|
||||
if CONFIGURABLE_PLUGINS[name] then
|
||||
configurable[name] = configurable[name] or {}
|
||||
configurable[name][#configurable[name] + 1] = cfg
|
||||
end
|
||||
|
||||
local data = ws[plugin.ws_id]
|
||||
if not data then
|
||||
data = new_ws_data()
|
||||
ws[plugin.ws_id] = data
|
||||
end
|
||||
local plugins = data.plugins
|
||||
local combos = data.combos
|
||||
|
||||
plugins[name] = true
|
||||
|
||||
-- Retrieve route_id, service_id, and consumer_id from the plugin object, if they exist
|
||||
local route_id = plugin.route and plugin.route.id
|
||||
local service_id = plugin.service and plugin.service.id
|
||||
local consumer_id = plugin.consumer and plugin.consumer.id
|
||||
|
||||
-- Determine if the plugin configuration is global (i.e., not tied to any route, service, consumer or group)
|
||||
if not (route_id or service_id or consumer_id) and plugin.ws_id == kong.default_workspace then
|
||||
-- Store the global configuration for the plugin in the 'globals' table
|
||||
globals[name] = cfg
|
||||
end
|
||||
|
||||
-- Initialize an empty table for the plugin in the 'combos' table if it doesn't already exist
|
||||
combos[name] = combos[name] or {}
|
||||
|
||||
-- Build a compound key using the route_id, service_id, and consumer_id
|
||||
local compound_key = build_compound_key(route_id, service_id, consumer_id)
|
||||
|
||||
-- Store the plugin configuration in the 'combos' table using the compound key
|
||||
combos[name][compound_key] = cfg
|
||||
end
|
||||
end
|
||||
|
||||
counter = counter + 1
|
||||
end
|
||||
|
||||
if has_plugins then
|
||||
-- loaded_plugins contains all the plugins that we _may_ execute
|
||||
for _, plugin in ipairs(LOADED_PLUGINS) do
|
||||
local name = plugin.name
|
||||
-- ws contains all the plugins that are associated to the request via route/service/global mappings
|
||||
for _, data in pairs(ws) do
|
||||
local plugins = data.plugins
|
||||
if plugins[name] then -- is the plugin associated to the request(workspace/route/service)?
|
||||
local n = plugins[0] + 1
|
||||
plugins[n] = plugin -- next item goes into next slot
|
||||
plugins[0] = n -- index 0 holds table size
|
||||
plugins[name] = nil -- remove the placeholder value
|
||||
end
|
||||
end
|
||||
|
||||
local cfg = globals[name]
|
||||
if cfg then
|
||||
for _, phase in ipairs(NON_COLLECTING_PHASES) do
|
||||
if plugin.handler[phase] then
|
||||
local plugins = globals[phase]
|
||||
local n = plugins[0] + 2
|
||||
plugins[0] = n
|
||||
plugins[n] = cfg
|
||||
plugins[n - 1] = plugin
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
::done::
|
||||
|
||||
return {
|
||||
version = version,
|
||||
ws = ws,
|
||||
loaded = LOADED_PLUGINS,
|
||||
configure = create_configure(configurable),
|
||||
globals = globals,
|
||||
get_init_worker_iterator = get_init_worker_iterator,
|
||||
get_global_iterator = get_global_iterator,
|
||||
get_collecting_iterator = get_collecting_iterator,
|
||||
get_collected_iterator = get_collected_iterator,
|
||||
has_plugins = has_plugins,
|
||||
release = release,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
-- for testing
|
||||
PluginsIterator.lookup_cfg = lookup_cfg
|
||||
PluginsIterator.build_compound_key = build_compound_key
|
||||
|
||||
|
||||
return PluginsIterator
|
||||
Reference in New Issue
Block a user