fix(preflight): address #41 review — persisted confirmation, deploy ready re-check, template-aware version
回应 Fasthei 复审(PR #51 CHANGES_REQUESTED): 1. 持久化确认记录(强一致):新增 model.PreflightConfirmation 表 + InsertPreflightConfirmation + PreflightConfirmationExists。confirm 时落库(默认 TTL=HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS =3600s,可设 0 不过期),写失败直接报错(非 best-effort)。部署侧要求该版本存在未过期确认记录 → 杜绝直接拿 GET version 绕过 confirm/审计。 2. 部署重新校验 Ready:verifyDeployPreflight 增加 summary.Ready 检查 —— 预算/agent_slot 等 易变项不进版本哈希,故部署时重查,防 confirm 后余额耗尽/槽位占满仍启动。 3. 版本哈希纳入模板安全面:computePreflightVersion 加 tplDigest(definition+model+name 摘要), 管理员改同一 template_key 的 definition/model 后旧确认失效。补 TestComputePreflightVersion_ChangesOnTemplateEdit。 4. 审计降为附加流:强一致确认记录作为部署门禁;审计 preflight.confirmed 互补。 测试:PreflightConfirmationExists(命中/版本不符/跨用户/过期/不过期/空参)、PreflightBindingKey、 模板变更翻转版本。TestMain + 生产迁移注册 PreflightConfirmation。controller+model 全回归通过。 文档 §4.1.1 更新。 Affects: Manager only(新增 preflight_confirmations 表 + 部署门禁强化)。无计费改动。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -223,8 +223,8 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 仅当 `ready=true` 才能确认;否则返回 `POLICY_REJECTED`(先补齐缺失项)。
|
- 仅当 `ready=true` 才能确认;否则返回 `POLICY_REJECTED`(先补齐缺失项)。
|
||||||
- 确认会写一条审计事件 `preflight.confirmed`(谁 / 何时 / 哪个 `version`)。
|
- 确认会**持久化一条强一致的确认记录**(user+template+version,默认 TTL `HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS`=3600s),并附带写一条审计事件 `preflight.confirmed`。`version` 的派生**已纳入模板安全面**(definition/model/name)——管理员改了同一模板的 definition/model,旧确认即失效。
|
||||||
- **部署校验**:`POST /api/heicode/agents` 可带 `preflight_version`。HM 用**当前**资源/模板状态重算版本并比对——确认后资源被增删/改类型/改凭证、或模板变更 → 版本不匹配 → 部署被拒(`preflight changed…,re-run confirm`)。
|
- **部署校验**:`POST /api/heicode/agents` 可带 `preflight_version`。HM 校验三件事:① 用**当前**资源/模板状态重算版本 == 传入(资源增删/改类型/改凭证、模板变更 → 不匹配即拒);② 当前摘要仍 `ready`(预算/agent_slot 等易变项重查,防 confirm 后余额耗尽仍启动);③ **存在该版本的未过期确认记录**(杜绝直接拿 GET 的 `version` 绕过 confirm)。
|
||||||
- 默认**仅在带了 `preflight_version` 时校验**(向后兼容,不带照常部署);设 `HEICODE_PREFLIGHT_REQUIRED=true` 则**强制**要求先 confirm。
|
- 默认**仅在带了 `preflight_version` 时校验**(向后兼容,不带照常部署);设 `HEICODE_PREFLIGHT_REQUIRED=true` 则**强制**要求先 confirm。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ type preflightSummary struct {
|
|||||||
ApprovalPolicy gin.H `json:"approval_policy"`
|
ApprovalPolicy gin.H `json:"approval_policy"`
|
||||||
Ready bool `json:"ready"` // 缺失项为空 + agent 配额未满
|
Ready bool `json:"ready"` // 缺失项为空 + agent 配额未满
|
||||||
Version string `json:"version,omitempty"` // 防篡改摘要版本(#41);由稳定子集派生
|
Version string `json:"version,omitempty"` // 防篡改摘要版本(#41);由稳定子集派生
|
||||||
|
|
||||||
|
// tplDigest 是模板**安全面**(definition + model + name)的摘要,纳入版本哈希,使管理员
|
||||||
|
// 修改同一 template_key 的 definition/model 后旧确认失效(#41 复审 #3)。不序列化。
|
||||||
|
tplDigest string
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmBindingIDs 返回摘要里有效(已解析)资源的绑定 id,用于审计记录。
|
// confirmBindingIDs 返回摘要里有效(已解析)资源的绑定 id,用于审计记录。
|
||||||
@@ -115,8 +119,9 @@ func (s preflightSummary) confirmBindingIDs() []int {
|
|||||||
// budget/agent_slot 缺失项,否则版本会无意义地频繁变化导致部署总被拒。资源(增删/改类型/
|
// budget/agent_slot 缺失项,否则版本会无意义地频繁变化导致部署总被拒。资源(增删/改类型/
|
||||||
// 改 secret)或高危面变化 → 哈希变化 → 部署校验拒绝(防篡改 / 防漂移)。
|
// 改 secret)或高危面变化 → 哈希变化 → 部署校验拒绝(防篡改 / 防漂移)。
|
||||||
func computePreflightVersion(s preflightSummary) string {
|
func computePreflightVersion(s preflightSummary) string {
|
||||||
parts := make([]string, 0, len(s.Resources)+len(s.HighRiskOps)+len(s.Missing)+1)
|
parts := make([]string, 0, len(s.Resources)+len(s.HighRiskOps)+len(s.Missing)+2)
|
||||||
parts = append(parts, "tpl="+s.TemplateID)
|
parts = append(parts, "tpl="+s.TemplateID)
|
||||||
|
parts = append(parts, "tpld="+s.tplDigest) // 模板安全面摘要(#41 复审 #3:模板变更翻转版本)
|
||||||
|
|
||||||
res := make([]string, 0, len(s.Resources))
|
res := make([]string, 0, len(s.Resources))
|
||||||
for _, r := range s.Resources {
|
for _, r := range s.Resources {
|
||||||
@@ -172,15 +177,29 @@ func verifyDeployPreflight(userID int, templateID string, bindingIDs []int, prov
|
|||||||
if required {
|
if required {
|
||||||
return false, "preflight confirmation required: call POST /api/heicode/preflight/confirm and pass its version as preflight_version"
|
return false, "preflight confirmation required: call POST /api/heicode/preflight/confirm and pass its version as preflight_version"
|
||||||
}
|
}
|
||||||
return true, ""
|
return true, "" // 向后兼容:未带 version 且非强制 → 放行
|
||||||
}
|
}
|
||||||
summary, ok := buildPreflightSummary(userID, templateID, normalizeBindingIDs(bindingIDs))
|
summary, ok := buildPreflightSummary(userID, templateID, normalizeBindingIDs(bindingIDs))
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, "unknown template_id"
|
return false, "unknown template_id"
|
||||||
}
|
}
|
||||||
|
// (a) 漂移/篡改防护:当前重算版本必须与传入一致(资源/模板安全面变化即翻转,#41 复审 #3)。
|
||||||
if computePreflightVersion(summary) != providedVersion {
|
if computePreflightVersion(summary) != providedVersion {
|
||||||
return false, "preflight changed since confirmation (resources/template drifted or tampered); re-run preflight confirm and retry"
|
return false, "preflight changed since confirmation (resources/template drifted or tampered); re-run preflight confirm and retry"
|
||||||
}
|
}
|
||||||
|
// (b) 当前仍须 Ready(#41 复审 #2):预算/agent_slot 等易变项不进版本哈希,故部署时重新校验,
|
||||||
|
// 防止用 confirm 时的 ready 版本在余额耗尽/槽位占满后仍能启动。
|
||||||
|
if !summary.Ready {
|
||||||
|
return false, "preflight no longer ready (e.g. budget/agent slot); re-run preflight and resolve missing items"
|
||||||
|
}
|
||||||
|
// (c) 必须存在一条该版本的已确认记录(#41 复审 #1/#4):杜绝直接拿 GET version 绕过 confirm。
|
||||||
|
exists, err := model.PreflightConfirmationExists(userID, templateID, providedVersion, common.GetTimestamp()*1000)
|
||||||
|
if err != nil {
|
||||||
|
return false, "failed to verify preflight confirmation"
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return false, "this preflight version was never confirmed (or has expired); call POST /api/heicode/preflight/confirm first"
|
||||||
|
}
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +279,16 @@ func computePreflight(tpl model.AgentTemplate, resources []preflightResource, in
|
|||||||
},
|
},
|
||||||
ApprovalPolicy: gin.H{"mode": "per_high_risk_op"},
|
ApprovalPolicy: gin.H{"mode": "per_high_risk_op"},
|
||||||
Ready: len(missing) == 0,
|
Ready: len(missing) == 0,
|
||||||
|
tplDigest: templateSecurityDigest(tpl),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// templateSecurityDigest 取模板安全面(definition + model + name)的短摘要,纳入版本哈希。
|
||||||
|
func templateSecurityDigest(tpl model.AgentTemplate) string {
|
||||||
|
sum := sha256.Sum256([]byte(tpl.Definition + "|" + tpl.Model + "|" + tpl.NameZh))
|
||||||
|
return hex.EncodeToString(sum[:])[:16]
|
||||||
|
}
|
||||||
|
|
||||||
// parsePreflightBindingIDs 解析 binding_ids 查询参数(支持逗号分隔 "1,2,3" 或重复 key)。
|
// parsePreflightBindingIDs 解析 binding_ids 查询参数(支持逗号分隔 "1,2,3" 或重复 key)。
|
||||||
func parsePreflightBindingIDs(c *gin.Context) []int {
|
func parsePreflightBindingIDs(c *gin.Context) []int {
|
||||||
raw := c.QueryArray("binding_ids")
|
raw := c.QueryArray("binding_ids")
|
||||||
@@ -382,13 +408,34 @@ func HeicodePreflightConfirm(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
version := computePreflightVersion(summary)
|
version := computePreflightVersion(summary)
|
||||||
|
bindingKey := model.PreflightBindingKey(req.BindingIDs)
|
||||||
|
|
||||||
// 审计:谁、何时、确认了哪个版本(防篡改版本号 + 模板 + 绑定)。
|
// 强一致的确认记录(#41 复审 #1/#4):部署侧据此校验「该版本曾被 confirm」。**先持久化成功**
|
||||||
bindingsJSON, _ := common.Marshal(summary.confirmBindingIDs())
|
// 才算确认;写失败直接报错(不像审计那样 best-effort),否则部署侧会因查不到记录而拒绝。
|
||||||
|
nowMs := common.GetTimestamp() * 1000
|
||||||
|
ttlSec := common.GetEnvOrDefault("HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS", 3600)
|
||||||
|
expiresAtMs := int64(0)
|
||||||
|
if ttlSec > 0 {
|
||||||
|
expiresAtMs = nowMs + int64(ttlSec)*1000
|
||||||
|
}
|
||||||
|
if err := model.InsertPreflightConfirmation(&model.PreflightConfirmation{
|
||||||
|
UserID: userID,
|
||||||
|
TemplateID: req.TemplateID,
|
||||||
|
BindingKey: bindingKey,
|
||||||
|
Version: version,
|
||||||
|
CreatedAtMs: nowMs,
|
||||||
|
ExpiresAtMs: expiresAtMs,
|
||||||
|
}); err != nil {
|
||||||
|
common.SysLog("preflight confirm persist failed: " + err.Error())
|
||||||
|
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist preflight confirmation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附加审计流:谁、何时、确认了哪个版本(best-effort,与上面的强一致记录互补)。
|
||||||
detail, _ := common.Marshal(gin.H{
|
detail, _ := common.Marshal(gin.H{
|
||||||
"version": version,
|
"version": version,
|
||||||
"template_id": req.TemplateID,
|
"template_id": req.TemplateID,
|
||||||
"binding_ids": string(bindingsJSON),
|
"binding_key": bindingKey,
|
||||||
})
|
})
|
||||||
model.InsertAgentAuditEvent(&model.AgentAuditEvent{
|
model.InsertAgentAuditEvent(&model.AgentAuditEvent{
|
||||||
Event: "preflight.confirmed",
|
Event: "preflight.confirmed",
|
||||||
|
|||||||
@@ -115,6 +115,21 @@ func TestComputePreflightVersion_ChangesOnTamper(t *testing.T) {
|
|||||||
require.NotEqual(t, v0, computePreflightVersion(added), "新增资源(引入 db_write 高危)应翻转版本")
|
require.NotEqual(t, v0, computePreflightVersion(added), "新增资源(引入 db_write 高危)应翻转版本")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #41 复审 #3:模板安全面(definition/model/name)变化 → 版本翻转(同一 template_key 被改也失效)。
|
||||||
|
func TestComputePreflightVersion_ChangesOnTemplateEdit(t *testing.T) {
|
||||||
|
res := []preflightResource{
|
||||||
|
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
||||||
|
}
|
||||||
|
base := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus", Definition: "v1 body"}, res, nil, 1_000_000, 500000, 5, 0)
|
||||||
|
v0 := computePreflightVersion(base)
|
||||||
|
|
||||||
|
editedDef := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus", Definition: "v2 body changed"}, res, nil, 1_000_000, 500000, 5, 0)
|
||||||
|
require.NotEqual(t, v0, computePreflightVersion(editedDef), "改 definition 应翻转版本")
|
||||||
|
|
||||||
|
editedModel := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "sonnet", Definition: "v1 body"}, res, nil, 1_000_000, 500000, 5, 0)
|
||||||
|
require.NotEqual(t, v0, computePreflightVersion(editedModel), "改 model 应翻转版本")
|
||||||
|
}
|
||||||
|
|
||||||
// #41: normalizeBindingIDs 去重 + 去非正数 + 保序。
|
// #41: normalizeBindingIDs 去重 + 去非正数 + 保序。
|
||||||
func TestNormalizeBindingIDs(t *testing.T) {
|
func TestNormalizeBindingIDs(t *testing.T) {
|
||||||
require.Equal(t, []int{3, 1, 2}, normalizeBindingIDs([]int{3, 1, 3, 0, 2, -5, 1}))
|
require.Equal(t, []int{3, 1, 2}, normalizeBindingIDs([]int{3, 1, 3, 0, 2, -5, 1}))
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ func migrateDB() error {
|
|||||||
// restart. See model/agent_audit.go for the rationale.
|
// restart. See model/agent_audit.go for the rationale.
|
||||||
&AgentAuditEvent{},
|
&AgentAuditEvent{},
|
||||||
&TelemetryEvent{},
|
&TelemetryEvent{},
|
||||||
|
&PreflightConfirmation{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreflightConfirmation 持久化一次 preflight「已确认版本」记录(#41 复审 #1/#4)。
|
||||||
|
//
|
||||||
|
// 为什么需要它:仅靠「部署时重算 hash == 传入 version」无法证明这个 version 曾被 confirm ——
|
||||||
|
// GET /api/heicode/preflight 也会返回同一个 version,客户端可绕过 confirm 与审计直接拿 GET
|
||||||
|
// version 去部署。把 confirm 落成一条**强一致、可查询**的记录,部署时校验该记录确实存在且未过期,
|
||||||
|
// 才真正满足「启动接口校验已确认版本」。审计事件(agent_audit_events)作为附加审计流。
|
||||||
|
type PreflightConfirmation struct {
|
||||||
|
Id int `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID int `gorm:"index" json:"user_id"`
|
||||||
|
TemplateID string `gorm:"type:varchar(64);index" json:"template_id"`
|
||||||
|
BindingKey string `gorm:"type:varchar(512);index" json:"binding_key"` // 归一化排序后的 binding ids,便于审计/排查
|
||||||
|
Version string `gorm:"type:varchar(64);index" json:"version"` // 防篡改摘要版本
|
||||||
|
CreatedAtMs int64 `gorm:"bigint;index" json:"created_at_ms"`
|
||||||
|
ExpiresAtMs int64 `gorm:"bigint;index" json:"expires_at_ms"` // 0 表示不过期
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PreflightConfirmation) TableName() string { return "preflight_confirmations" }
|
||||||
|
|
||||||
|
// PreflightBindingKey 把绑定 id 归一化(去重/去非正/升序)后拼成稳定 key,confirm 与 deploy
|
||||||
|
// 用同一算法,保证同一组绑定得到同一 key。
|
||||||
|
func PreflightBindingKey(bindingIDs []int) string {
|
||||||
|
seen := map[int]bool{}
|
||||||
|
ids := make([]int, 0, len(bindingIDs))
|
||||||
|
for _, n := range bindingIDs {
|
||||||
|
if n > 0 && !seen[n] {
|
||||||
|
seen[n] = true
|
||||||
|
ids = append(ids, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Ints(ids)
|
||||||
|
parts := make([]string, 0, len(ids))
|
||||||
|
for _, n := range ids {
|
||||||
|
parts = append(parts, strconv.Itoa(n))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertPreflightConfirmation 持久化一条确认记录。
|
||||||
|
func InsertPreflightConfirmation(rec *PreflightConfirmation) error {
|
||||||
|
if DB == nil || rec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return DB.Create(rec).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreflightConfirmationExists 校验存在一条匹配的、未过期的确认记录(#41 部署侧强校验)。
|
||||||
|
// 匹配 user + template + version(version 已编码资源+模板安全面);nowMs 用于过期判断,便于单测。
|
||||||
|
func PreflightConfirmationExists(userID int, templateID, version string, nowMs int64) (bool, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
version = strings.TrimSpace(version)
|
||||||
|
if userID <= 0 || strings.TrimSpace(templateID) == "" || version == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
err := DB.Model(&PreflightConfirmation{}).
|
||||||
|
Where("user_id = ? AND template_id = ? AND version = ?", userID, templateID, version).
|
||||||
|
Where("expires_at_ms = 0 OR expires_at_ms > ?", nowMs).
|
||||||
|
Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #41 复审 #1/#4:确认记录强校验 —— 存在/版本不符/过期/跨用户。
|
||||||
|
func TestPreflightConfirmationExists(t *testing.T) {
|
||||||
|
require.NoError(t, DB.Where("1 = 1").Delete(&PreflightConfirmation{}).Error)
|
||||||
|
const now = int64(1_700_000_000_000)
|
||||||
|
|
||||||
|
require.NoError(t, InsertPreflightConfirmation(&PreflightConfirmation{
|
||||||
|
UserID: 7, TemplateID: "architect", BindingKey: "1,2", Version: "pfv1_abc",
|
||||||
|
CreatedAtMs: now, ExpiresAtMs: now + 3600_000,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 命中
|
||||||
|
ok, err := PreflightConfirmationExists(7, "architect", "pfv1_abc", now+1000)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
// 版本不符 → 不命中(防止拿别的 version 部署)
|
||||||
|
ok, _ = PreflightConfirmationExists(7, "architect", "pfv1_OTHER", now+1000)
|
||||||
|
require.False(t, ok)
|
||||||
|
|
||||||
|
// 跨用户 → 不命中
|
||||||
|
ok, _ = PreflightConfirmationExists(9, "architect", "pfv1_abc", now+1000)
|
||||||
|
require.False(t, ok)
|
||||||
|
|
||||||
|
// 过期 → 不命中
|
||||||
|
ok, _ = PreflightConfirmationExists(7, "architect", "pfv1_abc", now+7200_000)
|
||||||
|
require.False(t, ok)
|
||||||
|
|
||||||
|
// 空参数 → 不命中
|
||||||
|
ok, _ = PreflightConfirmationExists(0, "architect", "pfv1_abc", now)
|
||||||
|
require.False(t, ok)
|
||||||
|
ok, _ = PreflightConfirmationExists(7, "architect", "", now)
|
||||||
|
require.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不过期记录(ExpiresAtMs=0)恒命中。
|
||||||
|
func TestPreflightConfirmationExists_NoExpiry(t *testing.T) {
|
||||||
|
require.NoError(t, DB.Where("1 = 1").Delete(&PreflightConfirmation{}).Error)
|
||||||
|
require.NoError(t, InsertPreflightConfirmation(&PreflightConfirmation{
|
||||||
|
UserID: 5, TemplateID: "t", Version: "v", CreatedAtMs: 1, ExpiresAtMs: 0,
|
||||||
|
}))
|
||||||
|
ok, err := PreflightConfirmationExists(5, "t", "v", 9_999_999_999_999)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreflightBindingKey_Normalizes(t *testing.T) {
|
||||||
|
require.Equal(t, "1,2,3", PreflightBindingKey([]int{3, 1, 2, 3, 0, -1, 1}))
|
||||||
|
require.Equal(t, "", PreflightBindingKey(nil))
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ func TestMain(m *testing.M) {
|
|||||||
&SubscriptionOrder{},
|
&SubscriptionOrder{},
|
||||||
&UserSubscription{},
|
&UserSubscription{},
|
||||||
&TelemetryEvent{},
|
&TelemetryEvent{},
|
||||||
|
&PreflightConfirmation{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
panic("failed to migrate: " + err.Error())
|
panic("failed to migrate: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user