Plugins: Unexport PluginDir field from PluginDTO (#59190)
* unexport pluginDir from dto * more err checks * tidy * fix tests * fix dboard file tests * fix import * fix tests * apply PR feedback * combine interfaces * fix logs and clean up test * filepath clean * use fs.File * rm explicit type
This commit is contained in:
@@ -2,10 +2,6 @@ package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
@@ -49,7 +45,7 @@ var RendererProvider PluginBackendProvider = func(_ context.Context, p *plugins.
|
||||
if !p.IsRenderer() {
|
||||
return nil
|
||||
}
|
||||
return grpcplugin.NewRendererPlugin(p.ID, filepath.Join(p.PluginDir, rendererStartCmd()),
|
||||
return grpcplugin.NewRendererPlugin(p.ID, p.ExecutablePath(),
|
||||
func(pluginID string, renderer pluginextensionv2.RendererPlugin, logger log.Logger) error {
|
||||
p.Renderer = renderer
|
||||
return nil
|
||||
@@ -61,7 +57,7 @@ var SecretsManagerProvider PluginBackendProvider = func(_ context.Context, p *pl
|
||||
if !p.IsSecretsManager() {
|
||||
return nil
|
||||
}
|
||||
return grpcplugin.NewSecretsManagerPlugin(p.ID, filepath.Join(p.PluginDir, secretsManagerStartCmd()),
|
||||
return grpcplugin.NewSecretsManagerPlugin(p.ID, p.ExecutablePath(),
|
||||
func(pluginID string, secretsmanager secretsmanagerplugin.SecretsManagerPlugin, logger log.Logger) error {
|
||||
p.SecretsManager = secretsmanager
|
||||
return nil
|
||||
@@ -70,42 +66,5 @@ var SecretsManagerProvider PluginBackendProvider = func(_ context.Context, p *pl
|
||||
}
|
||||
|
||||
var DefaultProvider PluginBackendProvider = func(_ context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc {
|
||||
// TODO check for executable
|
||||
return grpcplugin.NewBackendPlugin(p.ID, filepath.Join(p.PluginDir, pluginStartCmd(p.Executable)))
|
||||
}
|
||||
|
||||
func pluginStartCmd(executable string) string {
|
||||
os := strings.ToLower(runtime.GOOS)
|
||||
arch := runtime.GOARCH
|
||||
extension := ""
|
||||
|
||||
if os == "windows" {
|
||||
extension = ".exe"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s_%s%s", executable, os, strings.ToLower(arch), extension)
|
||||
}
|
||||
|
||||
func rendererStartCmd() string {
|
||||
os := strings.ToLower(runtime.GOOS)
|
||||
arch := runtime.GOARCH
|
||||
extension := ""
|
||||
|
||||
if os == "windows" {
|
||||
extension = ".exe"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s_%s%s", "plugin_start", os, strings.ToLower(arch), extension)
|
||||
}
|
||||
|
||||
func secretsManagerStartCmd() string {
|
||||
os := strings.ToLower(runtime.GOOS)
|
||||
arch := runtime.GOARCH
|
||||
extension := ""
|
||||
|
||||
if os == "windows" {
|
||||
extension = ".exe"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s_%s%s", "secrets_plugin_start", os, strings.ToLower(arch), extension)
|
||||
return grpcplugin.NewBackendPlugin(p.ID, p.ExecutablePath())
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
@@ -24,10 +22,8 @@ func ProvideFileStoreManager(pluginStore plugins.Store) *FileStoreManager {
|
||||
}
|
||||
}
|
||||
|
||||
var openDashboardFile = func(name string) (fs.File, error) {
|
||||
// Wrapping in filepath.Clean to properly handle
|
||||
// gosec G304 Potential file inclusion via variable rule.
|
||||
return os.Open(filepath.Clean(name))
|
||||
var openDashboardFile = func(p plugins.PluginDTO, name string) (fs.File, error) {
|
||||
return p.File(name)
|
||||
}
|
||||
|
||||
func (m *FileStoreManager) ListPluginDashboardFiles(ctx context.Context, args *ListPluginDashboardFilesArgs) (*ListPluginDashboardFilesResult, error) {
|
||||
@@ -90,8 +86,7 @@ func (m *FileStoreManager) GetPluginDashboardFileContents(ctx context.Context, a
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dashboardFilePath := filepath.Join(plugin.PluginDir, cleanPath)
|
||||
file, err := openDashboardFile(dashboardFilePath)
|
||||
file, err := openDashboardFile(plugin, cleanPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package dashboards
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
@@ -117,20 +118,24 @@ func TestDashboardFileStore(t *testing.T) {
|
||||
t.Run("With filesystem", func(t *testing.T) {
|
||||
origOpenDashboardFile := openDashboardFile
|
||||
mapFs := fstest.MapFS{
|
||||
"plugins/plugin-id/dashboards/dash1.json": {
|
||||
"dashboards/dash1.json": {
|
||||
Data: []byte("dash1"),
|
||||
},
|
||||
"plugins/plugin-id/dashboards/dash2.json": {
|
||||
"dashboards/dash2.json": {
|
||||
Data: []byte("dash2"),
|
||||
},
|
||||
"plugins/plugin-id/dashboards/dash3.json": {
|
||||
"dashboards/dash3.json": {
|
||||
Data: []byte("dash3"),
|
||||
},
|
||||
"plugins/plugin-id/dash2.json": {
|
||||
"dash2.json": {
|
||||
Data: []byte("dash2"),
|
||||
},
|
||||
}
|
||||
openDashboardFile = mapFs.Open
|
||||
openDashboardFile = func(p plugins.PluginDTO, name string) (fs.File, error) {
|
||||
f, err := mapFs.Open(name)
|
||||
require.NoError(t, err)
|
||||
return f, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
openDashboardFile = origOpenDashboardFile
|
||||
})
|
||||
@@ -156,7 +161,6 @@ func TestDashboardFileStore(t *testing.T) {
|
||||
b, err := io.ReadAll(res.Content)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "dash1", string(b))
|
||||
require.NoError(t, res.Content.Close())
|
||||
})
|
||||
|
||||
t.Run("Should return file content for dashboards/dash2.json", func(t *testing.T) {
|
||||
@@ -170,7 +174,6 @@ func TestDashboardFileStore(t *testing.T) {
|
||||
b, err := io.ReadAll(res.Content)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "dash2", string(b))
|
||||
require.NoError(t, res.Content.Close())
|
||||
})
|
||||
|
||||
t.Run("Should return error when trying to read relative file", func(t *testing.T) {
|
||||
@@ -189,39 +192,39 @@ func TestDashboardFileStore(t *testing.T) {
|
||||
func setupPluginDashboardsForTest(t *testing.T) *FileStoreManager {
|
||||
t.Helper()
|
||||
|
||||
return &FileStoreManager{
|
||||
pluginStore: &plugins.FakePluginStore{
|
||||
PluginList: []plugins.PluginDTO{
|
||||
p1 := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "pluginWithoutDashboards",
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "pluginWithoutDashboards",
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
Type: "page",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
PluginDir: "plugins/plugin-id",
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "pluginWithDashboards",
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
Type: "page",
|
||||
},
|
||||
{
|
||||
Type: "dashboard",
|
||||
Path: "dashboards/dash1.json",
|
||||
},
|
||||
{
|
||||
Type: "dashboard",
|
||||
Path: "dashboards/dash2.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
Type: "page",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
p2 := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "pluginWithDashboards",
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
Type: "page",
|
||||
},
|
||||
{
|
||||
Type: "dashboard",
|
||||
Path: "dashboards/dash1.json",
|
||||
},
|
||||
{
|
||||
Type: "dashboard",
|
||||
Path: "dashboards/dash2.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &FileStoreManager{
|
||||
pluginStore: &plugins.FakePluginStore{
|
||||
PluginList: []plugins.PluginDTO{p1.ToDTO(), p2.ToDTO()},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
|
||||
|
||||
if plugin.Info.Version == version {
|
||||
return plugins.DuplicateError{
|
||||
PluginID: plugin.ID,
|
||||
ExistingPluginDir: plugin.PluginDir,
|
||||
PluginID: plugin.ID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +64,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
|
||||
// if existing plugin version is the same as the target update version
|
||||
if dlOpts.Version == plugin.Info.Version {
|
||||
return plugins.DuplicateError{
|
||||
PluginID: plugin.ID,
|
||||
ExistingPluginDir: plugin.PluginDir,
|
||||
PluginID: plugin.ID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
|
||||
|
||||
err = inst.Add(context.Background(), pluginID, v1, plugins.CompatOpts{})
|
||||
require.Equal(t, plugins.DuplicateError{
|
||||
PluginID: pluginV1.ID,
|
||||
ExistingPluginDir: pluginV1.PluginDir,
|
||||
PluginID: pluginV1.ID,
|
||||
}, err)
|
||||
})
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO
|
||||
plugin.Signature = sig.Status
|
||||
plugin.SignatureType = sig.Type
|
||||
plugin.SignatureOrg = sig.SigningOrg
|
||||
plugin.SignedFiles = sig.Files
|
||||
|
||||
loadedPlugins[plugin.PluginDir] = plugin
|
||||
}
|
||||
|
||||
@@ -116,8 +116,8 @@ func TestIntegrationPluginManager(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
verifyCorePluginCatalogue(t, ctx, ps)
|
||||
verifyBundledPlugins(t, ctx, ps)
|
||||
verifyPluginStaticRoutes(t, ctx, ps)
|
||||
verifyBundledPlugins(t, ctx, ps, reg)
|
||||
verifyPluginStaticRoutes(t, ctx, ps, reg)
|
||||
verifyBackendProcesses(t, reg.Plugins(ctx))
|
||||
verifyPluginQuery(t, ctx, client.ProvideService(reg, pCfg))
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *store.Serv
|
||||
require.Equal(t, len(expPanels)+len(expDataSources)+len(expApps), len(ps.Plugins(ctx)))
|
||||
}
|
||||
|
||||
func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service) {
|
||||
func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service, reg registry.Service) {
|
||||
t.Helper()
|
||||
|
||||
dsPlugins := make(map[string]struct{})
|
||||
@@ -258,6 +258,9 @@ func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service)
|
||||
require.NotEqual(t, plugins.PluginDTO{}, inputPlugin)
|
||||
require.NotNil(t, dsPlugins["input"])
|
||||
|
||||
intInputPlugin, exists := reg.Plugin(ctx, "input")
|
||||
require.True(t, exists)
|
||||
|
||||
pluginRoutes := make(map[string]*plugins.StaticRoute)
|
||||
for _, r := range ps.Routes() {
|
||||
pluginRoutes[r.PluginID] = r
|
||||
@@ -265,23 +268,23 @@ func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service)
|
||||
|
||||
for _, pluginID := range []string{"input"} {
|
||||
require.Contains(t, pluginRoutes, pluginID)
|
||||
require.True(t, strings.HasPrefix(pluginRoutes[pluginID].Directory, inputPlugin.PluginDir))
|
||||
require.True(t, strings.HasPrefix(pluginRoutes[pluginID].Directory, intInputPlugin.PluginDir))
|
||||
}
|
||||
}
|
||||
|
||||
func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, ps *store.Service) {
|
||||
func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.StaticRouteResolver, reg registry.Service) {
|
||||
routes := make(map[string]*plugins.StaticRoute)
|
||||
for _, route := range ps.Routes() {
|
||||
for _, route := range rr.Routes() {
|
||||
routes[route.PluginID] = route
|
||||
}
|
||||
|
||||
require.Len(t, routes, 2)
|
||||
|
||||
inputPlugin, _ := ps.Plugin(ctx, "input")
|
||||
inputPlugin, _ := reg.Plugin(ctx, "input")
|
||||
require.NotNil(t, routes["input"])
|
||||
require.Equal(t, routes["input"].Directory, inputPlugin.PluginDir)
|
||||
|
||||
testAppPlugin, _ := ps.Plugin(ctx, "test-app")
|
||||
testAppPlugin, _ := reg.Plugin(ctx, "test-app")
|
||||
require.Contains(t, routes, "test-app")
|
||||
require.Equal(t, routes["test-app"].Directory, testAppPlugin.PluginDir)
|
||||
}
|
||||
|
||||
@@ -111,12 +111,7 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
|
||||
}, err
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(plugin.PluginDir, "MANIFEST.txt")
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `manifestPath` is based
|
||||
// on plugin the folder structure on disk and not user input.
|
||||
byteValue, err := os.ReadFile(manifestPath)
|
||||
byteValue := plugin.Manifest()
|
||||
if err != nil || len(byteValue) < 10 {
|
||||
mlog.Debug("Plugin is unsigned", "id", plugin.ID)
|
||||
return plugins.Signature{
|
||||
|
||||
@@ -54,7 +54,7 @@ func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError {
|
||||
SignatureStatus: plugins.SignatureUnsigned,
|
||||
}
|
||||
}
|
||||
s.log.Warn("Permitting unsigned plugin. This is not recommended", "pluginID", plugin.ID, "pluginDir", plugin.PluginDir)
|
||||
s.log.Warn("Permitting unsigned plugin. This is not recommended", "pluginID", plugin.ID)
|
||||
return nil
|
||||
case plugins.SignatureInvalid:
|
||||
s.log.Debug("Plugin has an invalid signature", "pluginID", plugin.ID)
|
||||
|
||||
@@ -26,12 +26,11 @@ func (e NotFoundError) Error() string {
|
||||
}
|
||||
|
||||
type DuplicateError struct {
|
||||
PluginID string
|
||||
ExistingPluginDir string
|
||||
PluginID string
|
||||
}
|
||||
|
||||
func (e DuplicateError) Error() string {
|
||||
return fmt.Sprintf("plugin with ID '%s' already exists in '%s'", e.PluginID, e.ExistingPluginDir)
|
||||
return fmt.Sprintf("plugin with ID '%s' already exists", e.PluginID)
|
||||
}
|
||||
|
||||
func (e DuplicateError) Is(err error) bool {
|
||||
@@ -195,13 +194,10 @@ func (s SignatureType) IsValid() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type PluginFiles map[string]struct{}
|
||||
|
||||
type Signature struct {
|
||||
Status SignatureStatus
|
||||
Type SignatureType
|
||||
SigningOrg string
|
||||
Files PluginFiles
|
||||
}
|
||||
|
||||
type PluginMetaDTO struct {
|
||||
|
||||
+60
-16
@@ -4,6 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -11,8 +16,11 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
var ErrFileNotExist = fmt.Errorf("file does not exist")
|
||||
|
||||
type Plugin struct {
|
||||
JSONData
|
||||
|
||||
@@ -30,7 +38,6 @@ type Plugin struct {
|
||||
SignatureOrg string
|
||||
Parent *Plugin
|
||||
Children []*Plugin
|
||||
SignedFiles PluginFiles
|
||||
SignatureError *SignatureError
|
||||
|
||||
// SystemJS fields
|
||||
@@ -46,8 +53,10 @@ type Plugin struct {
|
||||
type PluginDTO struct {
|
||||
JSONData
|
||||
|
||||
PluginDir string
|
||||
Class Class
|
||||
logger log.Logger
|
||||
pluginDir string
|
||||
|
||||
Class Class
|
||||
|
||||
// App fields
|
||||
IncludedInAppID string
|
||||
@@ -58,7 +67,6 @@ type PluginDTO struct {
|
||||
Signature SignatureStatus
|
||||
SignatureType SignatureType
|
||||
SignatureOrg string
|
||||
SignedFiles PluginFiles
|
||||
SignatureError *SignatureError
|
||||
|
||||
// SystemJS fields
|
||||
@@ -89,21 +97,29 @@ func (p PluginDTO) IsSecretsManager() bool {
|
||||
return p.JSONData.Type == SecretsManager
|
||||
}
|
||||
|
||||
func (p PluginDTO) IncludedInSignature(file string) bool {
|
||||
// permit Core plugin files
|
||||
if p.IsCorePlugin() {
|
||||
return true
|
||||
func (p PluginDTO) File(name string) (fs.File, error) {
|
||||
cleanPath, err := util.CleanRelativePath(name)
|
||||
if err != nil {
|
||||
// CleanRelativePath should clean and make the path relative so this is not expected to fail
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// permit when no signed files (no MANIFEST)
|
||||
if p.SignedFiles == nil {
|
||||
return true
|
||||
absPluginDir, err := filepath.Abs(p.pluginDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, exists := p.SignedFiles[file]; !exists {
|
||||
return false
|
||||
absFilePath := filepath.Join(absPluginDir, cleanPath)
|
||||
// Wrapping in filepath.Clean to properly handle
|
||||
// gosec G304 Potential file inclusion via variable rule.
|
||||
f, err := os.Open(filepath.Clean(absFilePath))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrFileNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return true
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// JSONData represents the plugin's plugin.json
|
||||
@@ -318,6 +334,25 @@ func (p *Plugin) Client() (PluginClient, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (p *Plugin) ExecutablePath() string {
|
||||
os := strings.ToLower(runtime.GOOS)
|
||||
arch := runtime.GOARCH
|
||||
extension := ""
|
||||
|
||||
if os == "windows" {
|
||||
extension = ".exe"
|
||||
}
|
||||
if p.IsRenderer() {
|
||||
return filepath.Join(p.PluginDir, fmt.Sprintf("%s_%s_%s%s", "plugin_start", os, strings.ToLower(arch), extension))
|
||||
}
|
||||
|
||||
if p.IsSecretsManager() {
|
||||
return filepath.Join(p.PluginDir, fmt.Sprintf("%s_%s_%s%s", "secrets_plugin_start", os, strings.ToLower(arch), extension))
|
||||
}
|
||||
|
||||
return filepath.Join(p.PluginDir, fmt.Sprintf("%s_%s_%s%s", p.Executable, os, strings.ToLower(arch), extension))
|
||||
}
|
||||
|
||||
type PluginClient interface {
|
||||
backend.QueryDataHandler
|
||||
backend.CollectMetricsHandler
|
||||
@@ -330,8 +365,9 @@ func (p *Plugin) ToDTO() PluginDTO {
|
||||
c, _ := p.Client()
|
||||
|
||||
return PluginDTO{
|
||||
logger: p.Logger(),
|
||||
pluginDir: p.PluginDir,
|
||||
JSONData: p.JSONData,
|
||||
PluginDir: p.PluginDir,
|
||||
Class: p.Class,
|
||||
IncludedInAppID: p.IncludedInAppID,
|
||||
DefaultNavURL: p.DefaultNavURL,
|
||||
@@ -339,7 +375,6 @@ func (p *Plugin) ToDTO() PluginDTO {
|
||||
Signature: p.Signature,
|
||||
SignatureType: p.SignatureType,
|
||||
SignatureOrg: p.SignatureOrg,
|
||||
SignedFiles: p.SignedFiles,
|
||||
SignatureError: p.SignatureError,
|
||||
Module: p.Module,
|
||||
BaseURL: p.BaseURL,
|
||||
@@ -387,6 +422,15 @@ func (p *Plugin) IsExternalPlugin() bool {
|
||||
return p.Class == External
|
||||
}
|
||||
|
||||
func (p *Plugin) Manifest() []byte {
|
||||
d, err := os.ReadFile(filepath.Join(p.PluginDir, "MANIFEST.txt"))
|
||||
if err != nil {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
type Class string
|
||||
|
||||
const (
|
||||
|
||||
Reference in New Issue
Block a user