feat(api): 添加从商店安装插件功能
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 49s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 57s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 51s
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 49s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 57s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 51s
- 添加 /store/install API 端点用于从商店安装插件 - 实现 StorePluginInstallRequest 请求结构体 - 添加 handleStoreInstall 处理函数实现插件下载和安装逻辑 - 在客户端视图中添加商店插件安装模态框和相关功能 - 在插件视图中添加商店插件安装按钮和安装到客户端功能 - 添加 installStorePlugin API 函数用于前端调用 - 实现客户端在线状态检查和插件安装流程
This commit is contained in:
@@ -131,6 +131,7 @@ func RegisterRoutes(r *Router, app AppInterface) {
|
|||||||
api.HandleFunc("/plugins", h.handlePlugins)
|
api.HandleFunc("/plugins", h.handlePlugins)
|
||||||
api.HandleFunc("/plugin/", h.handlePlugin)
|
api.HandleFunc("/plugin/", h.handlePlugin)
|
||||||
api.HandleFunc("/store/plugins", h.handleStorePlugins)
|
api.HandleFunc("/store/plugins", h.handleStorePlugins)
|
||||||
|
api.HandleFunc("/store/install", h.handleStoreInstall)
|
||||||
api.HandleFunc("/client-plugin/", h.handleClientPlugin)
|
api.HandleFunc("/client-plugin/", h.handleClientPlugin)
|
||||||
api.HandleFunc("/js-plugin/", h.handleJSPlugin)
|
api.HandleFunc("/js-plugin/", h.handleJSPlugin)
|
||||||
api.HandleFunc("/js-plugins", h.handleJSPlugins)
|
api.HandleFunc("/js-plugins", h.handleJSPlugins)
|
||||||
@@ -582,6 +583,13 @@ type StorePluginInfo struct {
|
|||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StorePluginInstallRequest 从商店安装插件的请求
|
||||||
|
type StorePluginInstallRequest struct {
|
||||||
|
PluginName string `json:"plugin_name"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
}
|
||||||
|
|
||||||
// handleStorePlugins 处理扩展商店插件列表
|
// handleStorePlugins 处理扩展商店插件列表
|
||||||
func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
@@ -625,6 +633,71 @@ func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleStoreInstall 从商店安装插件到客户端
|
||||||
|
func (h *APIHandler) handleStoreInstall(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req StorePluginInstallRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PluginName == "" || req.DownloadURL == "" || req.ClientID == "" {
|
||||||
|
http.Error(rw, "plugin_name, download_url and client_id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查客户端是否在线
|
||||||
|
online, _, _ := h.server.GetClientStatus(req.ClientID)
|
||||||
|
if !online {
|
||||||
|
http.Error(rw, "client not online", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载插件
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(req.DownloadURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "Failed to download plugin: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
http.Error(rw, "Plugin download failed with status: "+resp.Status, http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, "Failed to read plugin: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安装到客户端
|
||||||
|
installReq := JSPluginInstallRequest{
|
||||||
|
PluginName: req.PluginName,
|
||||||
|
Source: string(source),
|
||||||
|
RuleName: req.PluginName,
|
||||||
|
AutoStart: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.server.InstallJSPluginToClient(req.ClientID, installReq); err != nil {
|
||||||
|
http.Error(rw, "Failed to install plugin: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.jsonResponse(rw, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"plugin": req.PluginName,
|
||||||
|
"client": req.ClientID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleClientPlugin 处理客户端插件配置
|
// handleClientPlugin 处理客户端插件配置
|
||||||
// 路由: /api/client-plugin/{clientID}/{pluginName}/config
|
// 路由: /api/client-plugin/{clientID}/{pluginName}/config
|
||||||
func (h *APIHandler) handleClientPlugin(rw http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) handleClientPlugin(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
|
|||||||
|
|
||||||
// 扩展商店
|
// 扩展商店
|
||||||
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins')
|
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins')
|
||||||
|
export const installStorePlugin = (pluginName: string, downloadUrl: string, clientId: string) =>
|
||||||
|
post('/store/install', { plugin_name: pluginName, download_url: downloadUrl, client_id: clientId })
|
||||||
|
|
||||||
// 客户端插件配置
|
// 客户端插件配置
|
||||||
export const getClientPluginConfig = (clientId: string, pluginName: string) =>
|
export const getClientPluginConfig = (clientId: string, pluginName: string) =>
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import {
|
import {
|
||||||
NCard, NButton, NSpace, NTag, NTable, NEmpty,
|
NCard, NButton, NSpace, NTag, NTable, NEmpty,
|
||||||
NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox, NSwitch,
|
NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox, NSwitch,
|
||||||
NIcon, useMessage, useDialog
|
NIcon, useMessage, useDialog, NSpin
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
ArrowBackOutline, CreateOutline, TrashOutline,
|
ArrowBackOutline, CreateOutline, TrashOutline,
|
||||||
PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline,
|
PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline,
|
||||||
DownloadOutline, SettingsOutline
|
DownloadOutline, SettingsOutline, StorefrontOutline
|
||||||
} from '@vicons/ionicons5'
|
} from '@vicons/ionicons5'
|
||||||
import {
|
import {
|
||||||
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient,
|
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient,
|
||||||
getPlugins, installPluginsToClient, getClientPluginConfig, updateClientPluginConfig
|
getPlugins, installPluginsToClient, getClientPluginConfig, updateClientPluginConfig,
|
||||||
|
getStorePlugins, installStorePlugin
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type { ProxyRule, PluginInfo, ClientPlugin, ConfigField, RuleSchema } from '../types'
|
import type { ProxyRule, PluginInfo, ClientPlugin, ConfigField, RuleSchema, StorePluginInfo } from '../types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -77,6 +78,13 @@ const configSchema = ref<ConfigField[]>([])
|
|||||||
const configValues = ref<Record<string, string>>({})
|
const configValues = ref<Record<string, string>>({})
|
||||||
const configLoading = ref(false)
|
const configLoading = ref(false)
|
||||||
|
|
||||||
|
// 商店插件安装相关
|
||||||
|
const showStoreModal = ref(false)
|
||||||
|
const storePlugins = ref<StorePluginInfo[]>([])
|
||||||
|
const storeLoading = ref(false)
|
||||||
|
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
|
||||||
|
const storeInstalling = ref(false)
|
||||||
|
|
||||||
const loadPlugins = async () => {
|
const loadPlugins = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await getPlugins()
|
const { data } = await getPlugins()
|
||||||
@@ -110,6 +118,42 @@ const openInstallModal = async () => {
|
|||||||
showInstallModal.value = true
|
showInstallModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 商店插件相关函数
|
||||||
|
const openStoreModal = async () => {
|
||||||
|
showStoreModal.value = true
|
||||||
|
storeLoading.value = true
|
||||||
|
selectedStorePlugin.value = null
|
||||||
|
try {
|
||||||
|
const { data } = await getStorePlugins()
|
||||||
|
storePlugins.value = (data.plugins || []).filter(p => p.download_url)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load store plugins', e)
|
||||||
|
message.error('加载商店插件失败')
|
||||||
|
} finally {
|
||||||
|
storeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInstallStorePlugin = async (plugin: StorePluginInfo) => {
|
||||||
|
if (!plugin.download_url) {
|
||||||
|
message.error('该插件没有下载地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storeInstalling.value = true
|
||||||
|
selectedStorePlugin.value = plugin
|
||||||
|
try {
|
||||||
|
await installStorePlugin(plugin.name, plugin.download_url, clientId)
|
||||||
|
message.success(`已安装 ${plugin.name}`)
|
||||||
|
showStoreModal.value = false
|
||||||
|
await loadClient()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.response?.data || '安装失败')
|
||||||
|
} finally {
|
||||||
|
storeInstalling.value = false
|
||||||
|
selectedStorePlugin.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getTypeLabel = (type: string) => {
|
const getTypeLabel = (type: string) => {
|
||||||
const labels: Record<string, string> = { proxy: '协议', app: '应用', service: '服务', tool: '工具' }
|
const labels: Record<string, string> = { proxy: '协议', app: '应用', service: '服务', tool: '工具' }
|
||||||
return labels[type] || type
|
return labels[type] || type
|
||||||
@@ -350,6 +394,10 @@ const savePluginConfig = async () => {
|
|||||||
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
||||||
安装插件
|
安装插件
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-button @click="openStoreModal">
|
||||||
|
<template #icon><n-icon><StorefrontOutline /></n-icon></template>
|
||||||
|
从商店安装
|
||||||
|
</n-button>
|
||||||
<n-button type="warning" @click="disconnect">
|
<n-button type="warning" @click="disconnect">
|
||||||
<template #icon><n-icon><PowerOutline /></n-icon></template>
|
<template #icon><n-icon><PowerOutline /></n-icon></template>
|
||||||
断开连接
|
断开连接
|
||||||
@@ -598,5 +646,39 @@ const savePluginConfig = async () => {
|
|||||||
</n-space>
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- 商店插件安装模态框 -->
|
||||||
|
<n-modal v-model:show="showStoreModal" preset="card" title="从商店安装插件" style="width: 500px;">
|
||||||
|
<n-spin :show="storeLoading">
|
||||||
|
<n-empty v-if="!storeLoading && storePlugins.length === 0" description="商店暂无可用插件" />
|
||||||
|
<n-space v-else vertical :size="12">
|
||||||
|
<n-card v-for="plugin in storePlugins" :key="plugin.name" size="small">
|
||||||
|
<n-space justify="space-between" align="center">
|
||||||
|
<n-space vertical :size="4">
|
||||||
|
<n-space align="center">
|
||||||
|
<span style="font-weight: 500;">{{ plugin.name }}</span>
|
||||||
|
<n-tag size="small">v{{ plugin.version }}</n-tag>
|
||||||
|
</n-space>
|
||||||
|
<span style="color: #666; font-size: 12px;">{{ plugin.description }}</span>
|
||||||
|
<span style="color: #999; font-size: 12px;">作者: {{ plugin.author }}</span>
|
||||||
|
</n-space>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="storeInstalling && selectedStorePlugin?.name === plugin.name"
|
||||||
|
@click="handleInstallStorePlugin(plugin)"
|
||||||
|
>
|
||||||
|
安装
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
</n-space>
|
||||||
|
</n-spin>
|
||||||
|
<template #footer>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="showStoreModal = false">关闭</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { useRouter } from 'vue-router'
|
|||||||
import {
|
import {
|
||||||
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
|
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
|
||||||
NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage,
|
NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage,
|
||||||
NSelect
|
NSelect, NModal
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline } from '@vicons/ionicons5'
|
import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline } from '@vicons/ionicons5'
|
||||||
import { getPlugins, enablePlugin, disablePlugin, getStorePlugins, getJSPlugins, pushJSPluginToClient, getClients } from '../api'
|
import {
|
||||||
|
getPlugins, enablePlugin, disablePlugin, getStorePlugins, getJSPlugins,
|
||||||
|
pushJSPluginToClient, getClients, installStorePlugin
|
||||||
|
} from '../api'
|
||||||
import type { PluginInfo, StorePluginInfo, JSPlugin, ClientStatus } from '../types'
|
import type { PluginInfo, StorePluginInfo, JSPlugin, ClientStatus } from '../types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -141,6 +144,43 @@ const handlePushJSPlugin = async (pluginName: string, clientId: string) => {
|
|||||||
|
|
||||||
const onlineClients = computed(() => clients.value.filter(c => c.online))
|
const onlineClients = computed(() => clients.value.filter(c => c.online))
|
||||||
|
|
||||||
|
// 商店插件安装相关
|
||||||
|
const showInstallModal = ref(false)
|
||||||
|
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
|
||||||
|
const selectedClientId = ref('')
|
||||||
|
const installing = ref(false)
|
||||||
|
|
||||||
|
const openInstallModal = (plugin: StorePluginInfo) => {
|
||||||
|
selectedStorePlugin.value = plugin
|
||||||
|
selectedClientId.value = ''
|
||||||
|
showInstallModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInstallStorePlugin = async () => {
|
||||||
|
if (!selectedStorePlugin.value || !selectedClientId.value) {
|
||||||
|
message.warning('请选择要安装到的客户端')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedStorePlugin.value.download_url) {
|
||||||
|
message.error('该插件没有下载地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
installing.value = true
|
||||||
|
try {
|
||||||
|
await installStorePlugin(
|
||||||
|
selectedStorePlugin.value.name,
|
||||||
|
selectedStorePlugin.value.download_url,
|
||||||
|
selectedClientId.value
|
||||||
|
)
|
||||||
|
message.success(`已安装 ${selectedStorePlugin.value.name} 到客户端`)
|
||||||
|
showInstallModal.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.response?.data || '安装失败')
|
||||||
|
} finally {
|
||||||
|
installing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPlugins()
|
loadPlugins()
|
||||||
loadClients()
|
loadClients()
|
||||||
@@ -231,6 +271,16 @@ onMounted(() => {
|
|||||||
<span>{{ plugin.name }}</span>
|
<span>{{ plugin.name }}</span>
|
||||||
</n-space>
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button
|
||||||
|
v-if="plugin.download_url && onlineClients.length > 0"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click="openInstallModal(plugin)"
|
||||||
|
>
|
||||||
|
安装
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
<n-space vertical :size="8">
|
<n-space vertical :size="8">
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-tag size="small">v{{ plugin.version }}</n-tag>
|
<n-tag size="small">v{{ plugin.version }}</n-tag>
|
||||||
@@ -310,5 +360,33 @@ onMounted(() => {
|
|||||||
... 已屏蔽 ...
|
... 已屏蔽 ...
|
||||||
</n-modal>
|
</n-modal>
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
<!-- 安装商店插件模态框 -->
|
||||||
|
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件" style="width: 400px;">
|
||||||
|
<n-space vertical :size="16">
|
||||||
|
<div v-if="selectedStorePlugin">
|
||||||
|
<p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedStorePlugin.name }}</p>
|
||||||
|
<p style="margin: 0; color: #666;">{{ selectedStorePlugin.description }}</p>
|
||||||
|
</div>
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedClientId"
|
||||||
|
placeholder="选择要安装到的客户端"
|
||||||
|
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
|
||||||
|
/>
|
||||||
|
</n-space>
|
||||||
|
<template #footer>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="showInstallModal = false">取消</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:loading="installing"
|
||||||
|
:disabled="!selectedClientId"
|
||||||
|
@click="handleInstallStorePlugin"
|
||||||
|
>
|
||||||
|
安装
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user