diff --git a/admin_blocking_routes.go b/admin_blocking_routes.go new file mode 100644 index 0000000..d958060 --- /dev/null +++ b/admin_blocking_routes.go @@ -0,0 +1,211 @@ +package main + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type nodeBlockingRequest struct { + NodeID string `json:"node_id"` + NodeNum *int64 `json:"node_num"` + Reason string `json:"reason"` + Enabled bool `json:"enabled"` +} + +type ipBlockingRequest struct { + IPValue string `json:"ip_value"` + Reason string `json:"reason"` + Enabled bool `json:"enabled"` +} + +type forbiddenWordBlockingRequest struct { + Word string `json:"word"` + MatchType string `json:"match_type"` + CaseSensitive bool `json:"case_sensitive"` + Reason string `json:"reason"` + Enabled bool `json:"enabled"` +} + +func registerAdminBlockingRoutes(r gin.IRouter, store *store) { + r.GET("/blocking/nodes", func(c *gin.Context) { + opts, ok := parseListOptions(c) + if !ok { + return + } + rows, err := store.ListNodeBlocking(opts) + if err != nil { + writeListResponse(c, rows, opts, err, nodeBlockingDTO) + return + } + total, err := store.CountNodeBlocking(opts) + writeListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO) + }) + r.POST("/blocking/nodes", func(c *gin.Context) { + var req nodeBlockingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node blocking request"}) + return + } + row, err := store.CreateNodeBlocking(req.NodeID, req.NodeNum, req.Reason, req.Enabled) + writeBlockingMutationResponse(c, http.StatusCreated, row, err, nodeBlockingDTO) + }) + r.PUT("/blocking/nodes/:id", func(c *gin.Context) { + id, ok := parseBlockingID(c) + if !ok { + return + } + var req nodeBlockingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node blocking request"}) + return + } + row, err := store.UpdateNodeBlocking(id, req.NodeID, req.NodeNum, req.Reason, req.Enabled) + writeBlockingMutationResponse(c, http.StatusOK, row, err, nodeBlockingDTO) + }) + r.DELETE("/blocking/nodes/:id", func(c *gin.Context) { + id, ok := parseBlockingID(c) + if !ok { + return + } + writeBlockingDeleteResponse(c, store.DeleteNodeBlocking(id)) + }) + + r.GET("/blocking/ips", func(c *gin.Context) { + opts, ok := parseListOptions(c) + if !ok { + return + } + rows, err := store.ListIPBlocking(opts) + if err != nil { + writeListResponse(c, rows, opts, err, ipBlockingDTO) + return + } + total, err := store.CountIPBlocking(opts) + writeListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO) + }) + r.POST("/blocking/ips", func(c *gin.Context) { + var req ipBlockingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip blocking request"}) + return + } + row, err := store.CreateIPBlocking(req.IPValue, req.Reason, req.Enabled) + writeBlockingMutationResponse(c, http.StatusCreated, row, err, ipBlockingDTO) + }) + r.PUT("/blocking/ips/:id", func(c *gin.Context) { + id, ok := parseBlockingID(c) + if !ok { + return + } + var req ipBlockingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip blocking request"}) + return + } + row, err := store.UpdateIPBlocking(id, req.IPValue, req.Reason, req.Enabled) + writeBlockingMutationResponse(c, http.StatusOK, row, err, ipBlockingDTO) + }) + r.DELETE("/blocking/ips/:id", func(c *gin.Context) { + id, ok := parseBlockingID(c) + if !ok { + return + } + writeBlockingDeleteResponse(c, store.DeleteIPBlocking(id)) + }) + + r.GET("/blocking/words", func(c *gin.Context) { + opts, ok := parseListOptions(c) + if !ok { + return + } + rows, err := store.ListForbiddenWordBlocking(opts) + if err != nil { + writeListResponse(c, rows, opts, err, forbiddenWordBlockingDTO) + return + } + total, err := store.CountForbiddenWordBlocking(opts) + writeListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO) + }) + r.POST("/blocking/words", func(c *gin.Context) { + var req forbiddenWordBlockingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid forbidden word blocking request"}) + return + } + row, err := store.CreateForbiddenWordBlocking(req.Word, req.MatchType, req.CaseSensitive, req.Reason, req.Enabled) + writeBlockingMutationResponse(c, http.StatusCreated, row, err, forbiddenWordBlockingDTO) + }) + r.PUT("/blocking/words/:id", func(c *gin.Context) { + id, ok := parseBlockingID(c) + if !ok { + return + } + var req forbiddenWordBlockingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid forbidden word blocking request"}) + return + } + row, err := store.UpdateForbiddenWordBlocking(id, req.Word, req.MatchType, req.CaseSensitive, req.Reason, req.Enabled) + writeBlockingMutationResponse(c, http.StatusOK, row, err, forbiddenWordBlockingDTO) + }) + r.DELETE("/blocking/words/:id", func(c *gin.Context) { + id, ok := parseBlockingID(c) + if !ok { + return + } + writeBlockingDeleteResponse(c, store.DeleteForbiddenWordBlocking(id)) + }) +} + +func parseBlockingID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid blocking rule id"}) + return 0, false + } + return id, true +} + +func writeBlockingMutationResponse[T any](c *gin.Context, status int, row *T, err error, convert func(T) gin.H) { + if errors.Is(err, errBlockingAlreadyExists) { + c.JSON(http.StatusConflict, gin.H{"error": "blocking rule already exists"}) + return + } + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "blocking rule not found"}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(status, gin.H{"item": convert(*row)}) +} + +func writeBlockingDeleteResponse(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "blocking rule not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func nodeBlockingDTO(row nodeBlockingRecord) gin.H { + return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": ptrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} +} + +func ipBlockingDTO(row ipBlockingRecord) gin.H { + return gin.H{"id": row.ID, "ip_value": row.IPValue, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} +} + +func forbiddenWordBlockingDTO(row forbiddenWordBlockingRecord) gin.H { + return gin.H{"id": row.ID, "word": row.Word, "match_type": row.MatchType, "case_sensitive": row.CaseSensitive, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} +} diff --git a/blocking_store.go b/blocking_store.go new file mode 100644 index 0000000..764d37b --- /dev/null +++ b/blocking_store.go @@ -0,0 +1,310 @@ +package main + +import ( + "errors" + "fmt" + "net" + "strings" + "time" + + "gorm.io/gorm" +) + +const forbiddenWordMatchContains = "contains" + +var errBlockingAlreadyExists = errors.New("blocking rule already exists") + +func (s *store) ListNodeBlocking(opts listOptions) ([]nodeBlockingRecord, error) { + opts = normalizeListOptions(opts) + var rows []nodeBlockingRecord + q := s.db.Model(&nodeBlockingRecord{}). + Order("updated_at DESC"). + Order("id DESC"). + Limit(opts.Limit). + Offset(opts.Offset) + return rows, q.Find(&rows).Error +} + +func (s *store) CountNodeBlocking(opts listOptions) (int64, error) { + var total int64 + return total, s.db.Model(&nodeBlockingRecord{}).Count(&total).Error +} + +func (s *store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) { + nodeID = strings.TrimSpace(nodeID) + if nodeID == "" { + return nil, fmt.Errorf("node id is required") + } + if err := s.ensureNodeBlockingUnique(0, nodeID); err != nil { + return nil, err + } + row := nodeBlockingRecord{NodeID: nodeID, NodeNum: nodeNum, Reason: strings.TrimSpace(reason), Enabled: enabled} + if err := s.db.Create(&row).Error; err != nil { + return nil, err + } + return &row, nil +} + +func (s *store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) { + if id == 0 { + return nil, fmt.Errorf("blocking rule id is required") + } + nodeID = strings.TrimSpace(nodeID) + if nodeID == "" { + return nil, fmt.Errorf("node id is required") + } + if _, err := s.getNodeBlockingByID(id); err != nil { + return nil, err + } + if err := s.ensureNodeBlockingUnique(id, nodeID); err != nil { + return nil, err + } + updates := map[string]any{"node_id": nodeID, "node_num": nodeNum, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()} + if err := s.db.Model(&nodeBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return nil, err + } + return s.getNodeBlockingByID(id) +} + +func (s *store) DeleteNodeBlocking(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&nodeBlockingRecord{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *store) ListIPBlocking(opts listOptions) ([]ipBlockingRecord, error) { + opts = normalizeListOptions(opts) + var rows []ipBlockingRecord + q := s.db.Model(&ipBlockingRecord{}). + Order("updated_at DESC"). + Order("id DESC"). + Limit(opts.Limit). + Offset(opts.Offset) + return rows, q.Find(&rows).Error +} + +func (s *store) CountIPBlocking(opts listOptions) (int64, error) { + var total int64 + return total, s.db.Model(&ipBlockingRecord{}).Count(&total).Error +} + +func (s *store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) { + value, err := normalizeIPBlockingValue(ipValue) + if err != nil { + return nil, err + } + if err := s.ensureIPBlockingUnique(0, value); err != nil { + return nil, err + } + row := ipBlockingRecord{IPValue: value, Reason: strings.TrimSpace(reason), Enabled: enabled} + if err := s.db.Create(&row).Error; err != nil { + return nil, err + } + return &row, nil +} + +func (s *store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) { + if id == 0 { + return nil, fmt.Errorf("blocking rule id is required") + } + value, err := normalizeIPBlockingValue(ipValue) + if err != nil { + return nil, err + } + if _, err := s.getIPBlockingByID(id); err != nil { + return nil, err + } + if err := s.ensureIPBlockingUnique(id, value); err != nil { + return nil, err + } + updates := map[string]any{"ip_value": value, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()} + if err := s.db.Model(&ipBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return nil, err + } + return s.getIPBlockingByID(id) +} + +func (s *store) DeleteIPBlocking(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&ipBlockingRecord{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *store) ListForbiddenWordBlocking(opts listOptions) ([]forbiddenWordBlockingRecord, error) { + opts = normalizeListOptions(opts) + var rows []forbiddenWordBlockingRecord + q := s.db.Model(&forbiddenWordBlockingRecord{}). + Order("updated_at DESC"). + Order("id DESC"). + Limit(opts.Limit). + Offset(opts.Offset) + return rows, q.Find(&rows).Error +} + +func (s *store) CountForbiddenWordBlocking(opts listOptions) (int64, error) { + var total int64 + return total, s.db.Model(&forbiddenWordBlockingRecord{}).Count(&total).Error +} + +func (s *store) CreateForbiddenWordBlocking(word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) { + word = strings.TrimSpace(word) + if word == "" { + return nil, fmt.Errorf("forbidden word is required") + } + matchType, err := normalizeForbiddenWordMatchType(matchType) + if err != nil { + return nil, err + } + if err := s.ensureForbiddenWordBlockingUnique(0, word); err != nil { + return nil, err + } + row := forbiddenWordBlockingRecord{Word: word, MatchType: matchType, CaseSensitive: caseSensitive, Reason: strings.TrimSpace(reason), Enabled: enabled} + if err := s.db.Create(&row).Error; err != nil { + return nil, err + } + return &row, nil +} + +func (s *store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) { + if id == 0 { + return nil, fmt.Errorf("blocking rule id is required") + } + word = strings.TrimSpace(word) + if word == "" { + return nil, fmt.Errorf("forbidden word is required") + } + matchType, err := normalizeForbiddenWordMatchType(matchType) + if err != nil { + return nil, err + } + if _, err := s.getForbiddenWordBlockingByID(id); err != nil { + return nil, err + } + if err := s.ensureForbiddenWordBlockingUnique(id, word); err != nil { + return nil, err + } + updates := map[string]any{"word": word, "match_type": matchType, "case_sensitive": caseSensitive, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()} + if err := s.db.Model(&forbiddenWordBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return nil, err + } + return s.getForbiddenWordBlockingByID(id) +} + +func (s *store) DeleteForbiddenWordBlocking(id uint64) error { + result := s.db.Where("id = ?", id).Delete(&forbiddenWordBlockingRecord{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *store) getNodeBlockingByID(id uint64) (*nodeBlockingRecord, error) { + var row nodeBlockingRecord + if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, err + } + return &row, nil +} + +func (s *store) getIPBlockingByID(id uint64) (*ipBlockingRecord, error) { + var row ipBlockingRecord + if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, err + } + return &row, nil +} + +func (s *store) getForbiddenWordBlockingByID(id uint64) (*forbiddenWordBlockingRecord, error) { + var row forbiddenWordBlockingRecord + if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, err + } + return &row, nil +} + +func (s *store) ensureNodeBlockingUnique(id uint64, nodeID string) error { + var existing nodeBlockingRecord + q := s.db.Where("node_id = ?", nodeID) + if id != 0 { + q = q.Where("id <> ?", id) + } + err := q.Take(&existing).Error + if err == nil { + return errBlockingAlreadyExists + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err +} + +func (s *store) ensureIPBlockingUnique(id uint64, ipValue string) error { + var existing ipBlockingRecord + q := s.db.Where("ip_value = ?", ipValue) + if id != 0 { + q = q.Where("id <> ?", id) + } + err := q.Take(&existing).Error + if err == nil { + return errBlockingAlreadyExists + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err +} + +func (s *store) ensureForbiddenWordBlockingUnique(id uint64, word string) error { + var existing forbiddenWordBlockingRecord + q := s.db.Where("word = ?", word) + if id != 0 { + q = q.Where("id <> ?", id) + } + err := q.Take(&existing).Error + if err == nil { + return errBlockingAlreadyExists + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err +} + +func normalizeIPBlockingValue(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("ip value is required") + } + if ip := net.ParseIP(value); ip != nil { + return ip.String(), nil + } + _, ipNet, err := net.ParseCIDR(value) + if err == nil { + return ipNet.String(), nil + } + return "", fmt.Errorf("ip value must be a valid IP or CIDR") +} + +func normalizeForbiddenWordMatchType(matchType string) (string, error) { + matchType = strings.TrimSpace(matchType) + if matchType == "" { + return forbiddenWordMatchContains, nil + } + if matchType != forbiddenWordMatchContains { + return "", fmt.Errorf("unsupported forbidden word match type") + } + return matchType, nil +} diff --git a/blocking_store_test.go b/blocking_store_test.go new file mode 100644 index 0000000..1feec3c --- /dev/null +++ b/blocking_store_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "errors" + "testing" + + "gorm.io/gorm" +) + +func TestNodeBlockingCRUD(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + nodeNum := int64(305419896) + rule, err := st.CreateNodeBlocking(" !12345678 ", &nodeNum, " noisy node ", true) + if err != nil { + t.Fatalf("CreateNodeBlocking() error = %v", err) + } + if rule.NodeID != "!12345678" || rule.NodeNum == nil || *rule.NodeNum != nodeNum || rule.Reason != "noisy node" || !rule.Enabled { + t.Fatalf("created node rule = %+v, want normalized fields", rule) + } + + if _, err := st.CreateNodeBlocking("!12345678", nil, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) { + t.Fatalf("duplicate CreateNodeBlocking() error = %v, want errBlockingAlreadyExists", err) + } + + updatedNum := int64(7) + updated, err := st.UpdateNodeBlocking(rule.ID, "!00000007", &updatedNum, "updated", false) + if err != nil { + t.Fatalf("UpdateNodeBlocking() error = %v", err) + } + if updated.NodeID != "!00000007" || updated.NodeNum == nil || *updated.NodeNum != updatedNum || updated.Reason != "updated" || updated.Enabled { + t.Fatalf("updated node rule = %+v, want updated fields", updated) + } + + rows, err := st.ListNodeBlocking(listOptions{}) + if err != nil { + t.Fatalf("ListNodeBlocking() error = %v", err) + } + if len(rows) != 1 || rows[0].ID != rule.ID { + t.Fatalf("ListNodeBlocking() = %+v, want one updated rule", rows) + } + total, err := st.CountNodeBlocking(listOptions{}) + if err != nil || total != 1 { + t.Fatalf("CountNodeBlocking() = %d, %v, want 1, nil", total, err) + } + + if err := st.DeleteNodeBlocking(rule.ID); err != nil { + t.Fatalf("DeleteNodeBlocking() error = %v", err) + } + if err := st.DeleteNodeBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("DeleteNodeBlocking(missing) error = %v, want record not found", err) + } +} + +func TestNodeBlockingValidation(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if _, err := st.CreateNodeBlocking(" ", nil, "", true); err == nil { + t.Fatal("CreateNodeBlocking(empty) error = nil, want error") + } + if _, err := st.UpdateNodeBlocking(1, "!missing", nil, "", true); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("UpdateNodeBlocking(missing) error = %v, want record not found", err) + } +} + +func TestIPBlockingCRUDAndValidation(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + rule, err := st.CreateIPBlocking(" 127.0.0.1 ", "local", true) + if err != nil { + t.Fatalf("CreateIPBlocking(ip) error = %v", err) + } + if rule.IPValue != "127.0.0.1" || rule.Reason != "local" || !rule.Enabled { + t.Fatalf("created ip rule = %+v, want normalized IP", rule) + } + + cidr, err := st.CreateIPBlocking("192.168.1.99/24", "cidr", true) + if err != nil { + t.Fatalf("CreateIPBlocking(cidr) error = %v", err) + } + if cidr.IPValue != "192.168.1.0/24" { + t.Fatalf("cidr IPValue = %q, want 192.168.1.0/24", cidr.IPValue) + } + + if _, err := st.CreateIPBlocking("127.0.0.1", "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) { + t.Fatalf("duplicate CreateIPBlocking() error = %v, want errBlockingAlreadyExists", err) + } + if _, err := st.CreateIPBlocking("not-an-ip", "invalid", true); err == nil { + t.Fatal("CreateIPBlocking(invalid) error = nil, want error") + } + + updated, err := st.UpdateIPBlocking(rule.ID, "10.0.0.0/8", "updated", false) + if err != nil { + t.Fatalf("UpdateIPBlocking() error = %v", err) + } + if updated.IPValue != "10.0.0.0/8" || updated.Reason != "updated" || updated.Enabled { + t.Fatalf("updated ip rule = %+v, want updated fields", updated) + } + + rows, err := st.ListIPBlocking(listOptions{}) + if err != nil { + t.Fatalf("ListIPBlocking() error = %v", err) + } + if len(rows) != 2 { + t.Fatalf("ListIPBlocking() length = %d, want 2", len(rows)) + } + total, err := st.CountIPBlocking(listOptions{}) + if err != nil || total != 2 { + t.Fatalf("CountIPBlocking() = %d, %v, want 2, nil", total, err) + } + + if err := st.DeleteIPBlocking(rule.ID); err != nil { + t.Fatalf("DeleteIPBlocking() error = %v", err) + } + if err := st.DeleteIPBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("DeleteIPBlocking(missing) error = %v, want record not found", err) + } +} + +func TestForbiddenWordBlockingCRUDAndValidation(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + rule, err := st.CreateForbiddenWordBlocking(" spam ", "", false, "junk", true) + if err != nil { + t.Fatalf("CreateForbiddenWordBlocking() error = %v", err) + } + if rule.Word != "spam" || rule.MatchType != forbiddenWordMatchContains || rule.CaseSensitive || rule.Reason != "junk" || !rule.Enabled { + t.Fatalf("created word rule = %+v, want normalized fields", rule) + } + + if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) { + t.Fatalf("duplicate CreateForbiddenWordBlocking() error = %v, want errBlockingAlreadyExists", err) + } + if _, err := st.CreateForbiddenWordBlocking(" ", "contains", false, "empty", true); err == nil { + t.Fatal("CreateForbiddenWordBlocking(empty) error = nil, want error") + } + if _, err := st.CreateForbiddenWordBlocking("regex", "regex", false, "unsupported", true); err == nil { + t.Fatal("CreateForbiddenWordBlocking(unsupported match type) error = nil, want error") + } + + updated, err := st.UpdateForbiddenWordBlocking(rule.ID, "Spam", "contains", true, "updated", false) + if err != nil { + t.Fatalf("UpdateForbiddenWordBlocking() error = %v", err) + } + if updated.Word != "Spam" || updated.MatchType != "contains" || !updated.CaseSensitive || updated.Reason != "updated" || updated.Enabled { + t.Fatalf("updated word rule = %+v, want updated fields", updated) + } + + rows, err := st.ListForbiddenWordBlocking(listOptions{}) + if err != nil { + t.Fatalf("ListForbiddenWordBlocking() error = %v", err) + } + if len(rows) != 1 || rows[0].ID != rule.ID { + t.Fatalf("ListForbiddenWordBlocking() = %+v, want one updated rule", rows) + } + total, err := st.CountForbiddenWordBlocking(listOptions{}) + if err != nil || total != 1 { + t.Fatalf("CountForbiddenWordBlocking() = %d, %v, want 1, nil", total, err) + } + + if err := st.DeleteForbiddenWordBlocking(rule.ID); err != nil { + t.Fatalf("DeleteForbiddenWordBlocking() error = %v", err) + } + if err := st.DeleteForbiddenWordBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("DeleteForbiddenWordBlocking(missing) error = %v, want record not found", err) + } +} diff --git a/db.go b/db.go index 8c5f5ed..c4d8d12 100644 --- a/db.go +++ b/db.go @@ -111,6 +111,48 @@ func (discardDetailsRecord) TableName() string { return "discard_details" } +type nodeBlockingRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + NodeID string `gorm:"column:node_id;not null;uniqueIndex"` + NodeNum *int64 `gorm:"column:node_num;index"` + Reason string `gorm:"column:reason"` + Enabled bool `gorm:"column:enabled;not null;index"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` +} + +func (nodeBlockingRecord) TableName() string { + return "node_blocking" +} + +type ipBlockingRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + IPValue string `gorm:"column:ip_value;not null;uniqueIndex"` + Reason string `gorm:"column:reason"` + Enabled bool `gorm:"column:enabled;not null;index"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` +} + +func (ipBlockingRecord) TableName() string { + return "ip_blocking" +} + +type forbiddenWordBlockingRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + Word string `gorm:"column:word;not null;uniqueIndex"` + MatchType string `gorm:"column:match_type;not null;index"` + CaseSensitive bool `gorm:"column:case_sensitive;not null"` + Reason string `gorm:"column:reason"` + Enabled bool `gorm:"column:enabled;not null;index"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` +} + +func (forbiddenWordBlockingRecord) TableName() string { + return "forbidden_word_blocking" +} + type nodeInfoRecord struct { NodeID string `gorm:"column:node_id;primaryKey;not null"` NodeNum int64 `gorm:"column:node_num;not null;index"` @@ -306,6 +348,9 @@ func (s *store) migrate() error { {label: "users", model: &userRecord{}}, {label: "login_log", model: &loginLogRecord{}}, {label: "discard_details", model: &discardDetailsRecord{}}, + {label: "node_blocking", model: &nodeBlockingRecord{}}, + {label: "ip_blocking", model: &ipBlockingRecord{}}, + {label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}}, {label: "nodeinfo", model: &nodeInfoRecord{}}, {label: "map_report", model: &mapReportRecord{}}, {label: "text_message", model: &textMessageRecord{}}, diff --git a/db_test.go b/db_test.go index 7171762..a81b183 100644 --- a/db_test.go +++ b/db_test.go @@ -15,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) { st := openTestStore(t) defer st.Close() - for _, table := range []string{"users", "login_log", "discard_details", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { + for _, table := range []string{"users", "login_log", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { var name string if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil { t.Fatalf("%s table missing: %v", table, err) diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 2ddbdaf..f776ce4 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -1,6 +1,7 @@ + + diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index 7667d18..d6f455c 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -183,3 +183,58 @@ export interface AdminMqttStatus { packets_sent: number clients: AdminMqttClient[] } + +export interface NodeBlockingRule { + id: number + node_id: string + node_num: number | null + reason: string + enabled: boolean + created_at: string + updated_at: string +} + +export interface NodeBlockingRulePayload { + node_id: string + node_num: number | null + reason: string + enabled: boolean +} + +export interface IPBlockingRule { + id: number + ip_value: string + reason: string + enabled: boolean + created_at: string + updated_at: string +} + +export interface IPBlockingRulePayload { + ip_value: string + reason: string + enabled: boolean +} + +export interface ForbiddenWordBlockingRule { + id: number + word: string + match_type: string + case_sensitive: boolean + reason: string + enabled: boolean + created_at: string + updated_at: string +} + +export interface ForbiddenWordBlockingRulePayload { + word: string + match_type: string + case_sensitive: boolean + reason: string + enabled: boolean +} + +export interface BlockingRuleResponse { + item: T +} diff --git a/web.go b/web.go index c62442e..321a643 100644 --- a/web.go +++ b/web.go @@ -156,6 +156,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, protected := r.Group("") protected.Use(requireAdmin(sessions)) + registerAdminBlockingRoutes(protected, store) protected.GET("/me", func(c *gin.Context) { claims := c.MustGet("admin_claims").(*sessionClaims) c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})