mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
fix(plugin-store): preserve installed source identity
This commit is contained in:
@@ -59,32 +59,34 @@ type pluginStoreSourceErr struct {
|
||||
}
|
||||
|
||||
type pluginStoreListEntry struct {
|
||||
StoreID string `json:"store_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
SourceURL string `json:"source_url"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Repository string `json:"repository"`
|
||||
InstallType string `json:"install_type"`
|
||||
AuthRequired bool `json:"auth_required"`
|
||||
AuthConfigured bool `json:"auth_configured"`
|
||||
Platforms []pluginStorePlatform `json:"platforms,omitempty"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Installed bool `json:"installed"`
|
||||
InstalledVersion string `json:"installed_version"`
|
||||
Path string `json:"path"`
|
||||
Configured bool `json:"configured"`
|
||||
Registered bool `json:"registered"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EffectiveEnabled bool `json:"effective_enabled"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
StoreID string `json:"store_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
SourceURL string `json:"source_url"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Repository string `json:"repository"`
|
||||
InstallType string `json:"install_type"`
|
||||
AuthRequired bool `json:"auth_required"`
|
||||
AuthConfigured bool `json:"auth_configured"`
|
||||
Platforms []pluginStorePlatform `json:"platforms,omitempty"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Installed bool `json:"installed"`
|
||||
InstalledVersion string `json:"installed_version"`
|
||||
InstalledSourceID string `json:"installed_source_id,omitempty"`
|
||||
InstallSourceStatus string `json:"install_source_status,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Configured bool `json:"configured"`
|
||||
Registered bool `json:"registered"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EffectiveEnabled bool `json:"effective_enabled"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
}
|
||||
|
||||
type pluginStorePlatform struct {
|
||||
@@ -110,13 +112,16 @@ type pluginInstallRequest struct {
|
||||
}
|
||||
|
||||
type pluginLocalStatus struct {
|
||||
Installed bool
|
||||
InstalledVersion string
|
||||
Path string
|
||||
Configured bool
|
||||
Registered bool
|
||||
Enabled bool
|
||||
EffectiveEnabled bool
|
||||
Installed bool
|
||||
InstalledVersion string
|
||||
StoreManaged bool
|
||||
InstalledSourceID string
|
||||
InstalledSourceURL string
|
||||
Path string
|
||||
Configured bool
|
||||
Registered bool
|
||||
Enabled bool
|
||||
EffectiveEnabled bool
|
||||
}
|
||||
|
||||
type sourcedPlugin struct {
|
||||
@@ -148,11 +153,21 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
}
|
||||
client := h.newPluginStoreClient(proxyURL, "", storeAuth)
|
||||
latestVersions := h.latestPluginVersions(c.Request.Context(), client, latestInput)
|
||||
pluginSourceCounts := make(map[string]int, len(plugins))
|
||||
for _, item := range plugins {
|
||||
pluginSourceCounts[item.plugin.ID]++
|
||||
}
|
||||
|
||||
entries := make([]pluginStoreListEntry, 0, len(plugins))
|
||||
for index, item := range plugins {
|
||||
plugin := item.plugin
|
||||
status := statuses[plugin.ID]
|
||||
installedSourceID, installSourceStatus, sourceAllowsUpdate := pluginStoreInstallSourceStatus(
|
||||
status,
|
||||
sources,
|
||||
item.source.ID,
|
||||
pluginSourceCounts[plugin.ID],
|
||||
)
|
||||
installedVersion := status.InstalledVersion
|
||||
// Fall back to the registry version when the latest release is unknown.
|
||||
storeVersion := plugin.Version
|
||||
@@ -160,32 +175,34 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
storeVersion = latestVersions[index]
|
||||
}
|
||||
entries = append(entries, pluginStoreListEntry{
|
||||
StoreID: htmlsanitize.String(item.source.ID + "/" + plugin.ID),
|
||||
SourceID: htmlsanitize.String(item.source.ID),
|
||||
SourceName: htmlsanitize.String(item.source.Name),
|
||||
SourceURL: htmlsanitize.String(item.source.URL),
|
||||
ID: htmlsanitize.String(plugin.ID),
|
||||
Name: htmlsanitize.String(plugin.Name),
|
||||
Description: htmlsanitize.String(plugin.Description),
|
||||
Author: htmlsanitize.String(plugin.Author),
|
||||
Version: htmlsanitize.String(storeVersion),
|
||||
Repository: htmlsanitize.String(plugin.Repository),
|
||||
InstallType: htmlsanitize.String(pluginstore.PluginInstallType(plugin)),
|
||||
AuthRequired: plugin.AuthRequired,
|
||||
AuthConfigured: pluginAuthConfigured(item.source, plugin, storeAuth),
|
||||
Platforms: sanitizePluginStorePlatforms(pluginstore.PluginPlatforms(plugin)),
|
||||
Logo: htmlsanitize.String(plugin.Logo),
|
||||
Homepage: htmlsanitize.String(plugin.Homepage),
|
||||
License: htmlsanitize.String(plugin.License),
|
||||
Tags: htmlsanitize.Strings(plugin.Tags),
|
||||
Installed: status.Installed,
|
||||
InstalledVersion: htmlsanitize.String(installedVersion),
|
||||
Path: htmlsanitize.String(status.Path),
|
||||
Configured: status.Configured,
|
||||
Registered: status.Registered,
|
||||
Enabled: status.Enabled,
|
||||
EffectiveEnabled: status.EffectiveEnabled,
|
||||
UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, storeVersion),
|
||||
StoreID: htmlsanitize.String(item.source.ID + "/" + plugin.ID),
|
||||
SourceID: htmlsanitize.String(item.source.ID),
|
||||
SourceName: htmlsanitize.String(item.source.Name),
|
||||
SourceURL: htmlsanitize.String(item.source.URL),
|
||||
ID: htmlsanitize.String(plugin.ID),
|
||||
Name: htmlsanitize.String(plugin.Name),
|
||||
Description: htmlsanitize.String(plugin.Description),
|
||||
Author: htmlsanitize.String(plugin.Author),
|
||||
Version: htmlsanitize.String(storeVersion),
|
||||
Repository: htmlsanitize.String(plugin.Repository),
|
||||
InstallType: htmlsanitize.String(pluginstore.PluginInstallType(plugin)),
|
||||
AuthRequired: plugin.AuthRequired,
|
||||
AuthConfigured: pluginAuthConfigured(item.source, plugin, storeAuth),
|
||||
Platforms: sanitizePluginStorePlatforms(pluginstore.PluginPlatforms(plugin)),
|
||||
Logo: htmlsanitize.String(plugin.Logo),
|
||||
Homepage: htmlsanitize.String(plugin.Homepage),
|
||||
License: htmlsanitize.String(plugin.License),
|
||||
Tags: htmlsanitize.Strings(plugin.Tags),
|
||||
Installed: status.Installed,
|
||||
InstalledVersion: htmlsanitize.String(installedVersion),
|
||||
InstalledSourceID: htmlsanitize.String(installedSourceID),
|
||||
InstallSourceStatus: htmlsanitize.String(installSourceStatus),
|
||||
Path: htmlsanitize.String(status.Path),
|
||||
Configured: status.Configured,
|
||||
Registered: status.Registered,
|
||||
Enabled: status.Enabled,
|
||||
EffectiveEnabled: status.EffectiveEnabled,
|
||||
UpdateAvailable: sourceAllowsUpdate && pluginstore.UpdateAvailable(installedVersion, storeVersion),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,7 +230,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
return
|
||||
}
|
||||
installCtx := c.Request.Context()
|
||||
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, _, host := h.pluginStoreSnapshot()
|
||||
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, host := h.pluginStoreSnapshot()
|
||||
sources, errSources := h.pluginStoreSources(sourceConfigs)
|
||||
if errSources != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()})
|
||||
@@ -223,6 +240,9 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
if !okPlugin {
|
||||
return
|
||||
}
|
||||
if !validatePluginStoreInstallSource(c, configs, sources, id, source.ID) {
|
||||
return
|
||||
}
|
||||
pluginIsBusy := func() bool { return pluginBusy(host, id) }
|
||||
installOptions := pluginstore.InstallOptions{
|
||||
PluginsDir: pluginsDir,
|
||||
@@ -717,6 +737,7 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str
|
||||
status := statuses[id]
|
||||
status.Configured = true
|
||||
status.Enabled = pluginInstanceEnabled(item)
|
||||
status.InstalledSourceID, status.InstalledSourceURL, status.StoreManaged = pluginStoreConfiguredSource(item)
|
||||
statuses[id] = status
|
||||
}
|
||||
if host != nil {
|
||||
@@ -738,6 +759,95 @@ func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[str
|
||||
return statuses, nil
|
||||
}
|
||||
|
||||
func pluginStoreConfiguredSource(item config.PluginInstanceConfig) (sourceID string, sourceURL string, managed bool) {
|
||||
storeNode := pluginStoreConfigNode(item)
|
||||
if storeNode == nil {
|
||||
return "", "", false
|
||||
}
|
||||
var manifest pluginstore.Manifest
|
||||
if errDecode := storeNode.Decode(&manifest); errDecode != nil {
|
||||
return "", "", true
|
||||
}
|
||||
return strings.TrimSpace(manifest.SourceID), strings.TrimSpace(manifest.SourceURL), true
|
||||
}
|
||||
|
||||
func pluginStoreResolveInstalledSource(status pluginLocalStatus, sources []pluginstore.Source) (string, bool) {
|
||||
sourceID := strings.TrimSpace(status.InstalledSourceID)
|
||||
sourceURL := strings.TrimSpace(status.InstalledSourceURL)
|
||||
if sourceID != "" {
|
||||
for _, source := range sources {
|
||||
if strings.TrimSpace(source.ID) != sourceID {
|
||||
continue
|
||||
}
|
||||
if sourceURL != "" && strings.TrimSpace(source.URL) != sourceURL {
|
||||
return "", false
|
||||
}
|
||||
return sourceID, true
|
||||
}
|
||||
return sourceID, true
|
||||
}
|
||||
if sourceURL == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, source := range sources {
|
||||
if strings.TrimSpace(source.URL) == sourceURL {
|
||||
return strings.TrimSpace(source.ID), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func pluginStoreInstallSourceStatus(status pluginLocalStatus, sources []pluginstore.Source, entrySourceID string, sourceCount int) (installedSourceID string, sourceStatus string, allowUpdate bool) {
|
||||
if !status.Installed && !status.Configured && !status.Registered {
|
||||
return "", "", true
|
||||
}
|
||||
if sourceID, known := pluginStoreResolveInstalledSource(status, sources); known {
|
||||
if sourceID == strings.TrimSpace(entrySourceID) {
|
||||
return sourceID, "matched", true
|
||||
}
|
||||
return sourceID, "different", false
|
||||
}
|
||||
if status.StoreManaged || sourceCount > 1 {
|
||||
return "", "unknown", false
|
||||
}
|
||||
return "", "assumed", true
|
||||
}
|
||||
|
||||
func validatePluginStoreInstallSource(c *gin.Context, configs map[string]config.PluginInstanceConfig, sources []pluginstore.Source, id string, requestedSourceID string) bool {
|
||||
item, configured := configs[id]
|
||||
if !configured {
|
||||
return true
|
||||
}
|
||||
installedSourceID, installedSourceURL, managed := pluginStoreConfiguredSource(item)
|
||||
if !managed {
|
||||
return true
|
||||
}
|
||||
status := pluginLocalStatus{
|
||||
StoreManaged: true,
|
||||
InstalledSourceID: installedSourceID,
|
||||
InstalledSourceURL: installedSourceURL,
|
||||
}
|
||||
resolvedSourceID, known := pluginStoreResolveInstalledSource(status, sources)
|
||||
if !known {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "plugin_store_installed_source_unknown",
|
||||
"message": "installed plugin source cannot be verified; uninstall it before reinstalling from the store",
|
||||
"requested_source_id": strings.TrimSpace(requestedSourceID),
|
||||
})
|
||||
return false
|
||||
}
|
||||
if resolvedSourceID != strings.TrimSpace(requestedSourceID) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "plugin_store_source_conflict",
|
||||
"message": "installed plugin belongs to a different store source; uninstall it before switching sources",
|
||||
"installed_source_id": resolvedSourceID,
|
||||
"requested_source_id": strings.TrimSpace(requestedSourceID),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pluginStoreDesiredVersions(configs map[string]config.PluginInstanceConfig) map[string]string {
|
||||
if len(configs) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -402,6 +402,154 @@ func TestListPluginStoreIncludesThirdPartySources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreMatchesInstalledStatusToManifestSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
archDir := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH)
|
||||
if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", archDir, errMkdirAll)
|
||||
}
|
||||
pluginPath := filepath.Join(archDir, "sample-provider-v0.0.1"+managementPluginExtension(runtime.GOOS))
|
||||
if errWriteFile := os.WriteFile(pluginPath, []byte("x"), 0o644); errWriteFile != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", pluginPath, errWriteFile)
|
||||
}
|
||||
|
||||
communityURL := "https://community.example/registry.json"
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: pluginsDir,
|
||||
StoreSources: []string{communityURL},
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample-provider": pluginConfigWithStoreSource(t, pluginstore.DefaultSourceID, pluginstore.DefaultRegistryURL),
|
||||
},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
pluginstore.DefaultRegistryURL: registryJSON(t),
|
||||
communityURL: thirdPartySampleRegistryJSON(t),
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil)
|
||||
h.ListPluginStore(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Plugins []struct {
|
||||
SourceID string `json:"source_id"`
|
||||
InstalledSourceID string `json:"installed_source_id"`
|
||||
InstallSourceStatus string `json:"install_source_status"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
if len(body.Plugins) != 2 {
|
||||
t.Fatalf("plugins len = %d, want 2", len(body.Plugins))
|
||||
}
|
||||
entries := make(map[string]struct {
|
||||
InstalledSourceID string
|
||||
InstallSourceStatus string
|
||||
UpdateAvailable bool
|
||||
}, len(body.Plugins))
|
||||
for _, entry := range body.Plugins {
|
||||
entries[entry.SourceID] = struct {
|
||||
InstalledSourceID string
|
||||
InstallSourceStatus string
|
||||
UpdateAvailable bool
|
||||
}{entry.InstalledSourceID, entry.InstallSourceStatus, entry.UpdateAvailable}
|
||||
}
|
||||
official := entries[pluginstore.DefaultSourceID]
|
||||
if official.InstalledSourceID != pluginstore.DefaultSourceID || official.InstallSourceStatus != "matched" || !official.UpdateAvailable {
|
||||
t.Fatalf("official entry = %#v, want matched update", official)
|
||||
}
|
||||
communitySourceID := pluginstore.SourceID(communityURL)
|
||||
community := entries[communitySourceID]
|
||||
if community.InstalledSourceID != pluginstore.DefaultSourceID || community.InstallSourceStatus != "different" || community.UpdateAvailable {
|
||||
t.Fatalf("community entry = %#v, want different source without update", community)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreRejectsImplicitSourceSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
communityURL := "https://community.example/registry.json"
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: writeManagementPluginFile(t, "sample-provider"),
|
||||
StoreSources: []string{communityURL},
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample-provider": pluginConfigWithStoreSource(t, pluginstore.DefaultSourceID, pluginstore.DefaultRegistryURL),
|
||||
},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
pluginstore.DefaultRegistryURL: registryJSON(t),
|
||||
communityURL: thirdPartySampleRegistryJSON(t),
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
|
||||
communitySourceID := pluginstore.SourceID(communityURL)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install?source="+communitySourceID, nil)
|
||||
h.InstallPluginFromStore(c)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusConflict, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "plugin_store_source_conflict") || !strings.Contains(rec.Body.String(), pluginstore.DefaultSourceID) {
|
||||
t.Fatalf("body = %s, want source conflict with installed source", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreRejectsUnknownManagedSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: writeManagementPluginFile(t, "sample-provider"),
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample-provider": pluginConfigWithStoreSource(t, "", ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreRegistryURL: "https://registry.example/registry.json",
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
"https://registry.example/registry.json": registryJSON(t),
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install", nil)
|
||||
h.InstallPluginFromStore(c)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusConflict, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "plugin_store_installed_source_unknown") {
|
||||
t.Fatalf("body = %s, want unknown installed source error", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreIncludesDirectMetadataAndAuth(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STORE_TOKEN", "secret-token")
|
||||
|
||||
@@ -1147,6 +1295,18 @@ func testStoreManifest() pluginstore.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
func pluginConfigWithStoreSource(t *testing.T, sourceID string, sourceURL string) config.PluginInstanceConfig {
|
||||
t.Helper()
|
||||
sourceFields := ""
|
||||
if sourceID != "" {
|
||||
sourceFields += " source-id: " + sourceID + "\n"
|
||||
}
|
||||
if sourceURL != "" {
|
||||
sourceFields += " source-url: " + sourceURL + "\n"
|
||||
}
|
||||
return pluginConfigFromYAML(t, "enabled: true\nstore:\n schema-version: 1\n id: sample-provider\n version: 0.0.1\n release-tag: v0.0.1\n repository: https://github.com/author-name/cliproxy-sample-provider-plugin\n"+sourceFields+" install:\n type: github-release\n")
|
||||
}
|
||||
|
||||
func pluginStoreManifestFromConfig(t *testing.T, item config.PluginInstanceConfig) pluginstore.Manifest {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user