mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 08:00:53 +00:00
feat: improve alert config handling (#13001)
This commit is contained in:
+167
-17
@@ -1,6 +1,9 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
@@ -353,56 +356,203 @@ var singletonTypes = map[string]bool{
|
||||
|
||||
func (a *AlertRepo) SyncAll(data []model.AlertConfig) error {
|
||||
tx := global.AlertDB.Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
var oldConfigs []model.AlertConfig
|
||||
_ = tx.Find(&oldConfigs).Error
|
||||
if err := tx.Find(&oldConfigs).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
usedConfigIDs, err := loadUsedAlertConfigIDs(tx)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
oldConfigMap := make(map[string]uint)
|
||||
nonSingletonTypes := make(map[string]struct{})
|
||||
oldConfigByType := make(map[string][]model.AlertConfig)
|
||||
oldConfigByKey := make(map[string][]model.AlertConfig)
|
||||
consumedConfigIDs := make(map[uint]struct{})
|
||||
for _, item := range oldConfigs {
|
||||
if singletonTypes[item.Type] {
|
||||
oldConfigMap[item.Type] = item.ID
|
||||
continue
|
||||
}
|
||||
nonSingletonTypes[item.Type] = struct{}{}
|
||||
}
|
||||
for _, item := range data {
|
||||
if !singletonTypes[item.Type] {
|
||||
nonSingletonTypes[item.Type] = struct{}{}
|
||||
}
|
||||
}
|
||||
for itemType := range nonSingletonTypes {
|
||||
if err := tx.Where("type = ?", itemType).Delete(&model.AlertConfig{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
oldConfigByType[item.Type] = append(oldConfigByType[item.Type], item)
|
||||
oldConfigByKey[alertConfigSyncKey(item)] = append(oldConfigByKey[alertConfigSyncKey(item)], item)
|
||||
}
|
||||
for _, item := range data {
|
||||
if singletonTypes[item.Type] {
|
||||
if val, ok := oldConfigMap[item.Type]; ok {
|
||||
item.ID = val
|
||||
delete(oldConfigMap, item.Type)
|
||||
consumedConfigIDs[item.ID] = struct{}{}
|
||||
} else {
|
||||
item.ID = 0
|
||||
}
|
||||
if err := tx.Model(model.AlertConfig{}).Where("id = ?", item.ID).Save(&item).Error; err != nil {
|
||||
if item.ID == 0 {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
} else if err := tx.Save(&item).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
key := alertConfigSyncKey(item)
|
||||
if matched, ok := popAlertConfigByKey(oldConfigByKey, key); ok {
|
||||
item.ID = matched.ID
|
||||
consumedConfigIDs[item.ID] = struct{}{}
|
||||
if err := tx.Save(&item).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
deleteAlertConfigByID(oldConfigByType, matched.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
if matched, ok := popUnusedAlertConfigByType(oldConfigByType, usedConfigIDs, item.Type); ok {
|
||||
item.ID = matched.ID
|
||||
consumedConfigIDs[item.ID] = struct{}{}
|
||||
if err := tx.Save(&item).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
item.ID = 0
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range oldConfigMap {
|
||||
if err := tx.Where("id = ?", id).Delete(&model.AlertConfig{}).Error; err != nil {
|
||||
for _, item := range oldConfigs {
|
||||
if _, used := usedConfigIDs[item.ID]; used {
|
||||
continue
|
||||
}
|
||||
if _, kept := consumedConfigIDs[item.ID]; kept {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("id = ?", item.ID).Delete(&model.AlertConfig{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadUsedAlertConfigIDs(tx *gorm.DB) (map[uint]struct{}, error) {
|
||||
var alerts []model.Alert
|
||||
if err := tx.Select("method").Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
usedIDs := make(map[uint]struct{})
|
||||
for _, alert := range alerts {
|
||||
for _, item := range strings.Split(alert.Method, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseUint(item, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
usedIDs[uint(id)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return usedIDs, nil
|
||||
}
|
||||
|
||||
func alertConfigSyncKey(item model.AlertConfig) string {
|
||||
return item.Type + "::" + normalizeAlertConfigJSON(item.Config)
|
||||
}
|
||||
|
||||
func normalizeAlertConfigJSON(config string) string {
|
||||
trimmed := strings.TrimSpace(config)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var data any
|
||||
if err := json.Unmarshal([]byte(trimmed), &data); err != nil {
|
||||
return trimmed
|
||||
}
|
||||
buf, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return trimmed
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func popAlertConfigByKey(configMap map[string][]model.AlertConfig, key string) (model.AlertConfig, bool) {
|
||||
items := configMap[key]
|
||||
if len(items) == 0 {
|
||||
return model.AlertConfig{}, false
|
||||
}
|
||||
|
||||
item := items[0]
|
||||
if len(items) == 1 {
|
||||
delete(configMap, key)
|
||||
} else {
|
||||
configMap[key] = items[1:]
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
|
||||
func popUnusedAlertConfigByType(configMap map[string][]model.AlertConfig, usedConfigIDs map[uint]struct{}, configType string) (model.AlertConfig, bool) {
|
||||
items := configMap[configType]
|
||||
if len(items) == 0 {
|
||||
return model.AlertConfig{}, false
|
||||
}
|
||||
|
||||
for idx, item := range items {
|
||||
if _, used := usedConfigIDs[item.ID]; used {
|
||||
continue
|
||||
}
|
||||
if idx == 0 {
|
||||
if len(items) == 1 {
|
||||
delete(configMap, configType)
|
||||
} else {
|
||||
configMap[configType] = items[1:]
|
||||
}
|
||||
} else {
|
||||
configMap[configType] = append(items[:idx], items[idx+1:]...)
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
return model.AlertConfig{}, false
|
||||
}
|
||||
|
||||
func deleteAlertConfigByID(configMap map[string][]model.AlertConfig, id uint) {
|
||||
for key, items := range configMap {
|
||||
for idx, item := range items {
|
||||
if item.ID != id {
|
||||
continue
|
||||
}
|
||||
if len(items) == 1 {
|
||||
delete(configMap, key)
|
||||
} else {
|
||||
configMap[key] = append(items[:idx], items[idx+1:]...)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +512,9 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
|
||||
if err := a.checkAlertConfigDisplayNameUnique(req); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.checkAlertConfigSMSPhoneUnique(req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.ID != 0 {
|
||||
upMap := make(map[string]interface{})
|
||||
upMap["id"] = req.ID
|
||||
@@ -538,6 +541,29 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate) error {
|
||||
if req.Type != constant.SMSConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
phone := alertConfigSMSPhone(req.Config)
|
||||
configs, err := alertRepo.AlertConfigList(alertRepo.WithByType(req.Type))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, config := range configs {
|
||||
if req.ID != 0 && config.ID == req.ID {
|
||||
continue
|
||||
}
|
||||
if alertConfigSMSPhone(config.Config) == phone {
|
||||
return buserr.New("ErrAlertConfigPhoneExist")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdate) error {
|
||||
displayName := alertConfigDisplayName(req.Type, req.Config)
|
||||
if displayName == "" {
|
||||
@@ -604,7 +630,7 @@ func (a AlertService) validateCommunityAlertConfigType(configType string) error
|
||||
|
||||
func alertConfigDisplayName(configType, configData string) string {
|
||||
switch configType {
|
||||
case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark:
|
||||
case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark, constant.SMS:
|
||||
var cfg struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
@@ -617,6 +643,16 @@ func alertConfigDisplayName(configType, configData string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func alertConfigSMSPhone(configData string) string {
|
||||
var cfg struct {
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(configData), &cfg); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.Phone)
|
||||
}
|
||||
|
||||
func (a AlertService) DeleteAlertConfig(id uint) error {
|
||||
_, err := alertRepo.GetConfigById(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -78,6 +78,7 @@ ErrBackupCheck: 'Backup account connectivity test failed: {{ .err }}'
|
||||
ErrBackupLocalDelete: 'Local backup accounts cannot be deleted'
|
||||
ErrBackupLocalCreate: 'Local backup accounts cannot be created'
|
||||
ErrAlertConfigInUse: 'Alert configuration is in use and cannot be deleted'
|
||||
ErrAlertConfigPhoneExist: 'Phone number already exists'
|
||||
|
||||
#app
|
||||
ErrPortInUsed: '{{ .detail }} port is already occupied!'
|
||||
|
||||
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Conexión de respaldo falló: {{ .err }}'
|
||||
ErrBackupLocalDelete: 'No se puede eliminar cuentas de respaldo locales'
|
||||
ErrBackupLocalCreate: 'No se pueden crear cuentas de respaldo locales'
|
||||
ErrAlertConfigInUse: 'La configuración de alertas está en uso y no se puede eliminar'
|
||||
ErrAlertConfigPhoneExist: 'El numero de telefono ya existe'
|
||||
ErrPortInUsed: 'El puerto {{ .detail }} ya está ocupado'
|
||||
ErrAppLimit: 'El número de aplicaciones instaladas ha superado el límite'
|
||||
ErrNotInstall: 'Aplicación no instalada'
|
||||
|
||||
@@ -79,6 +79,7 @@ ErrBackupCheck: '接続テストに失敗しました: {{ .err }}'
|
||||
ErrBackupLocalDelete: 'ローカルバックアップは削除できません'
|
||||
ErrBackupLocalCreate: 'ローカルバックアップは作成できません'
|
||||
ErrAlertConfigInUse: 'アラート設定は使用中のため削除できません'
|
||||
ErrAlertConfigPhoneExist: '電話番号はすでに存在します'
|
||||
ErrPortInUsed: '{{ .detail }} ポートはすでに使用されています'
|
||||
ErrAppLimit: 'インストールされているアプリケーションの数が制限を超えました'
|
||||
ErrNotInstall: 'アプリケーションがインストールされていません'
|
||||
|
||||
@@ -79,6 +79,7 @@ ErrBackupCheck: '연결 테스트 실패: {{ .err }}'
|
||||
ErrBackupLocalDelete: '로컬 백업은 삭제할 수 없습니다'
|
||||
ErrBackupLocalCreate: '로컬 백업은 만들 수 없습니다'
|
||||
ErrAlertConfigInUse: '경고 설정이 사용 중이므로 삭제할 수 없습니다'
|
||||
ErrAlertConfigPhoneExist: '전화번호가 이미 존재합니다'
|
||||
ErrPortInUsed: '{{ .detail }} 포트가 이미 사용 중입니다'
|
||||
ErrAppLimit: '설치된 애플리케이션 수가 한도를 초과했습니다'
|
||||
ErrNotInstall: '응용 프로그램이 설치되지 않았습니다'
|
||||
|
||||
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Ujian sambungan gagal: {{ .err }}'
|
||||
ErrBackupLocalDelete: 'Tidak boleh padam sandaran tempatan'
|
||||
ErrBackupLocalCreate: 'Tidak boleh buat sandaran tempatan'
|
||||
ErrAlertConfigInUse: 'Konfigurasi amaran sedang digunakan dan tidak boleh dipadamkan'
|
||||
ErrAlertConfigPhoneExist: 'Nombor telefon sudah wujud'
|
||||
ErrPortInUsed: 'Port {{ .detail }} sudah diduduki'
|
||||
ErrAppLimit: 'Bilangan aplikasi yang dipasang telah melebihi had'
|
||||
ErrNotInstall: 'Aplikasi tidak dipasang'
|
||||
|
||||
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Teste de conexão falhou: {{ .err }}'
|
||||
ErrBackupLocalDelete: 'Não é permitido excluir contas locais'
|
||||
ErrBackupLocalCreate: 'Não é permitido criar contas locais'
|
||||
ErrAlertConfigInUse: 'A configuração de alerta está em uso e não pode ser excluída'
|
||||
ErrAlertConfigPhoneExist: 'O numero de telefone ja existe'
|
||||
ErrPortInUsed: 'A porta {{ .detail }} já está ocupada'
|
||||
ErrAppLimit: 'O número de aplicativos instalados excedeu o limite'
|
||||
ErrNotInstall: 'Aplicativo não instalado'
|
||||
|
||||
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Проверка подключения не удалась: {{
|
||||
ErrBackupLocalDelete: 'Нельзя удалить локальные бэкапы'
|
||||
ErrBackupLocalCreate: 'Нельзя создать локальные бэкапы'
|
||||
ErrAlertConfigInUse: 'Конфигурация оповещений используется и не может быть удалена'
|
||||
ErrAlertConfigPhoneExist: 'Номер телефона уже существует'
|
||||
ErrPortInUsed: '{{ .detail }} порт уже занят'
|
||||
ErrAppLimit: 'Количество установленных приложений превысило лимит'
|
||||
ErrNotInstall: 'Приложение не установлено'
|
||||
|
||||
@@ -79,6 +79,7 @@ ErrBackupCheck: 'Bağlantı testi başarısız: {{ .err }}'
|
||||
ErrBackupLocalDelete: 'Yerel yedek silme yok'
|
||||
ErrBackupLocalCreate: 'Yerel yedek oluşturma yok'
|
||||
ErrAlertConfigInUse: 'Uyarı yapılandırması kullanımda ve silinemez'
|
||||
ErrAlertConfigPhoneExist: 'Telefon numarasi zaten mevcut'
|
||||
ErrPortInUsed: '{{ .detail }} portu zaten kullanılıyor'
|
||||
ErrAppLimit: 'Yüklenen uygulama sayısı sınırı aştı'
|
||||
ErrNotInstall: 'Uygulama yüklenmedi'
|
||||
|
||||
@@ -79,6 +79,7 @@ ErrBackupCheck: '備份帳號測試連線失敗{{ .err }}'
|
||||
ErrBackupLocalDelete: '暫時不支援刪除本機伺服器備份帳號'
|
||||
ErrBackupLocalCreate: '暫時不支援建立本機伺服器備份帳號'
|
||||
ErrAlertConfigInUse: '告警配置正在使用中,無法刪除'
|
||||
ErrAlertConfigPhoneExist: '手機號碼已存在'
|
||||
ErrPortInUsed: '{{ .detail }} 連接埠已被佔用!'
|
||||
ErrAppLimit: '應用程式超出安裝數量限制'
|
||||
ErrNotInstall: '應用程式未安裝'
|
||||
|
||||
@@ -78,6 +78,7 @@ ErrBackupCheck: "备份账号测试连接失败 {{ .err }}"
|
||||
ErrBackupLocalDelete: "暂不支持删除本地服务器备份账号"
|
||||
ErrBackupLocalCreate: "暂不支持创建本地服务器备份账号"
|
||||
ErrAlertConfigInUse: "告警配置正在使用中,无法删除"
|
||||
ErrAlertConfigPhoneExist: "手机号已存在"
|
||||
|
||||
#app
|
||||
ErrPortInUsed: "{{ .detail }} 端口已被占用!"
|
||||
|
||||
@@ -9,7 +9,7 @@ const alertConfigHiddenTypes = ['sms'];
|
||||
const resolveAlertConfigExcludeTypes = (excludeTypes: string[] = []) => {
|
||||
const globalStore = GlobalStore();
|
||||
const types = new Set(excludeTypes);
|
||||
if (globalStore.isIntl || globalStore.isEE) {
|
||||
if (globalStore.isIntl || globalStore.isEE || !globalStore.isProductPro) {
|
||||
alertConfigHiddenTypes.forEach((type) => types.add(type));
|
||||
}
|
||||
return Array.from(types);
|
||||
|
||||
@@ -275,15 +275,15 @@
|
||||
class="flex items-center flex-row md:flex-nowrap sm:flex-nowrap flex-wrap justify-between gap-2 w-full"
|
||||
>
|
||||
<el-form-item prop="cycle" class="md:flex-1 sm:flex-1">
|
||||
<el-input v-model.number="dialogData.rowData!.cycle" :max="200" width="200px">
|
||||
<template #append>{{ $t('commons.units.minute') }}11</template>
|
||||
<el-input v-model.number="dialogData.rowData!.cycle" :max="200">
|
||||
<template #append>{{ $t('commons.units.minute') }}</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<span class="whitespace-nowrap input-help w-[4.5rem]">
|
||||
<span class="whitespace-nowrap input-help !w-[5rem]">
|
||||
{{ $t('xpack.alert.loginFail') }}
|
||||
</span>
|
||||
<el-form-item prop="count" class="md:flex-1 sm:flex-1 w-auto">
|
||||
<el-form-item prop="count" class="md:flex-1 sm:flex-1">
|
||||
<el-input v-model.number="dialogData.rowData!.count">
|
||||
<template #append>{{ $t('commons.units.time') }}</template>
|
||||
</el-input>
|
||||
@@ -398,7 +398,7 @@ import { routerToName } from '@/utils/router';
|
||||
import { checkCidr, checkCidrV6, checkIpV4V6 } from '@/utils/validate';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
|
||||
const { isMaster, isProductPro, isEE } = useGlobalStore();
|
||||
const { isMaster, isProductPro, isEE, isIntl } = useGlobalStore();
|
||||
|
||||
const alertConfigs = ref<Alert.AlertConfigInfo[]>([]);
|
||||
const loadAlertConfigs = async () => {
|
||||
@@ -423,7 +423,7 @@ const configOptions = computed(() => {
|
||||
type: c.type,
|
||||
disabled:
|
||||
c.status !== 'Enable' ||
|
||||
(!isProductPro.value && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(c.type)),
|
||||
((isIntl.value || !isProductPro.value) && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(c.type)),
|
||||
}))
|
||||
.sort((a, b) => Number(a.disabled) - Number(b.disabled));
|
||||
});
|
||||
|
||||
@@ -156,7 +156,7 @@ const emailRules = {
|
||||
|
||||
const smsRules = {
|
||||
smsDisplayName: [Rules.requiredInput, { validator: checkSmsDisplayNameDuplicate, trigger: 'blur' }],
|
||||
smsPhone: [Rules.requiredInput, Rules.phone, { validator: checkPhoneDuplicate, trigger: 'blur' }],
|
||||
smsPhone: [Rules.phone, { validator: checkPhoneDuplicate, trigger: 'blur' }],
|
||||
smsDailyAlertNum: [Rules.integerNumber, checkNumberRange(20, 100)],
|
||||
};
|
||||
|
||||
@@ -302,10 +302,6 @@ function checkSmsDisplayNameDuplicate(_rule: unknown, value: string, callback: (
|
||||
|
||||
function checkPhoneDuplicate(_rule: unknown, value: string, callback: (error?: Error) => void) {
|
||||
const currentValue = normalizeDisplayName(value);
|
||||
if (!currentValue) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicated = alertConfigs.value.some((item) => {
|
||||
if (item.type !== 'sms') {
|
||||
|
||||
@@ -151,7 +151,7 @@ import { MsgSuccess } from '@/utils/message';
|
||||
import AlertDrawer from '@/views/setting/alert/setting/drawer/index.vue';
|
||||
import { Alert } from '@/api/interface/alert';
|
||||
|
||||
const { docsUrl, isMaster, isMobile, isProductPro, isEE } = useGlobalStore();
|
||||
const { docsUrl, isMaster, isMobile, isProductPro, isEE, isIntl } = useGlobalStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const alertDrawerRef = ref();
|
||||
@@ -483,7 +483,7 @@ const buttons = computed(() => [
|
||||
openEditDrawer(row);
|
||||
},
|
||||
disabled: (row: Alert.AlertConfigInfo) =>
|
||||
!isProductPro.value && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type),
|
||||
(isIntl.value || !isProductPro.value) && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type),
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('commons.button.delete'),
|
||||
|
||||
Reference in New Issue
Block a user