diff --git a/api/api/custom_rules.go b/api/api/custom_rules.go index 719d67c3..540eb20a 100644 --- a/api/api/custom_rules.go +++ b/api/api/custom_rules.go @@ -7,6 +7,7 @@ import ( "github.com/ivpn/dns/api/api/requests" "github.com/ivpn/dns/api/api/responses" "github.com/ivpn/dns/api/internal/auth" + "github.com/ivpn/dns/api/service/profile" "github.com/rs/zerolog/log" ) @@ -105,6 +106,147 @@ func (s *APIServer) createProfileCustomRulesBatch() fiber.Handler { return handler } +// @Summary Update profile custom rule +// @Description Partially update a single custom rule in place (value, action, note, group, order) +// @Tags Profile +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param profile_id path string true "Profile ID" +// @Param custom_rule_id path string true "Custom rule ID" +// @Param body body requests.UpdateProfileCustomRuleBody true "Update custom rule request" +// @Success 200 {object} model.CustomRule +// @Failure 400 {object} ErrResponse +// @Failure 404 {object} ErrResponse +// @Failure 500 {object} ErrResponse +// @Router /api/v1/profiles/{profile_id}/custom_rules/{custom_rule_id} [patch] +func (s *APIServer) updateProfileCustomRule() fiber.Handler { + handler := func(c *fiber.Ctx) error { + profileId := c.Params("profile_id") + customRuleId := c.Params("custom_rule_id") + + p := new(requests.UpdateProfileCustomRuleBody) + if err := c.BodyParser(p); err != nil { + return HandleError(c, err, ErrInvalidRequestBody.Error()) + } + + errMsgs := s.Validator.ValidateRequest(c, p, ErrFailedToUpdateCustomRule.Error()) + if len(errMsgs) > 0 { + return HandleError(c, ErrInvalidCustomRuleSyntax, strings.Join(errMsgs, " and ")) + } + + accountId := auth.GetAccountID(c) + rule, err := s.Service.UpdateCustomRule(c.Context(), accountId, profileId, customRuleId, profile.CustomRulePatch{ + Action: p.Action, + Value: p.Value, + Note: p.Note, + Group: p.Group, + Order: p.Order, + }) + if err != nil { + return HandleError(c, err, ErrFailedToUpdateCustomRule.Error()) + } + + return c.Status(fiber.StatusOK).JSON(rule) + } + return handler +} + +// @Summary Reorder profile custom rules +// @Description Set the display order of a profile's custom rules. Order is organizational only and does not affect filtering precedence. +// @Tags Profile +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id path string true "Profile ID" +// @Param body body requests.ReorderProfileCustomRulesBody true "Ordered rule IDs" +// @Success 200 +// @Failure 400 {object} ErrResponse +// @Failure 404 {object} ErrResponse +// @Failure 500 {object} ErrResponse +// @Router /api/v1/profiles/{id}/custom_rules/order [patch] +func (s *APIServer) reorderProfileCustomRules() fiber.Handler { + handler := func(c *fiber.Ctx) error { + profileId := c.Params("id") + + p := new(requests.ReorderProfileCustomRulesBody) + if err := c.BodyParser(p); err != nil { + return HandleError(c, err, ErrInvalidRequestBody.Error()) + } + + errMsgs := s.Validator.ValidateRequest(c, p, ErrFailedToUpdateCustomRule.Error()) + if len(errMsgs) > 0 { + return HandleError(c, ErrInvalidRequestBody, strings.Join(errMsgs, " and ")) + } + + accountId := auth.GetAccountID(c) + if err := s.Service.ReorderCustomRules(c.Context(), accountId, profileId, p.Order); err != nil { + return HandleError(c, err, ErrFailedToUpdateCustomRule.Error()) + } + + return c.SendStatus(200) + } + return handler +} + +// decodeJSONPointerSegment turns a single-segment RFC6901 JSON Pointer ("/") +// into the plain group name, unescaping ~1 -> "/" and ~0 -> "~". Returns "" for an +// empty pointer or "/". +func decodeJSONPointerSegment(pointer string) string { + seg := strings.TrimPrefix(pointer, "/") + // Unescape in the RFC6901-mandated order: ~1 before ~0. + seg = strings.ReplaceAll(seg, "~1", "/") + seg = strings.ReplaceAll(seg, "~0", "~") + return seg +} + +// @Summary Update profile custom rule groups +// @Description Apply JSON-Patch-style operations to the custom-rule group registry. Group names travel in the JSON-Pointer path/from (never the URL). operation=add|replace sets a group's note (creating it); remove deletes a group (its rules move to Ungrouped, not deleted); move renames from->path. +// @Tags Profile +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id path string true "Profile ID" +// @Param body body requests.CustomRuleGroupUpdates true "Group operations" +// @Success 200 +// @Failure 400 {object} ErrResponse +// @Failure 500 {object} ErrResponse +// @Router /api/v1/profiles/{id}/custom_rule_groups [patch] +func (s *APIServer) updateProfileCustomRuleGroups() fiber.Handler { + handler := func(c *fiber.Ctx) error { + profileId := c.Params("id") + + p := new(requests.CustomRuleGroupUpdates) + if err := c.BodyParser(p); err != nil { + return HandleError(c, err, ErrInvalidRequestBody.Error()) + } + + errMsgs := s.Validator.ValidateRequest(c, p, ErrFailedToUpdateCustomRule.Error()) + if len(errMsgs) > 0 { + return HandleError(c, ErrInvalidRequestBody, strings.Join(errMsgs, " and ")) + } + + ops := make([]profile.CustomRuleGroupOp, 0, len(p.Updates)) + for _, u := range p.Updates { + ops = append(ops, profile.CustomRuleGroupOp{ + Operation: u.Operation, + Action: u.Action, + Group: decodeJSONPointerSegment(u.Path), + From: decodeJSONPointerSegment(u.From), + Note: u.Value, + }) + } + + accountId := auth.GetAccountID(c) + if err := s.Service.ApplyCustomRuleGroupOps(c.Context(), accountId, profileId, ops); err != nil { + return HandleError(c, err, ErrFailedToUpdateCustomRule.Error()) + } + + return c.SendStatus(200) + } + return handler +} + // @Summary Delete profile custom rule // @Description Delete profile custom rule // @Tags Profile diff --git a/api/api/errors.go b/api/api/errors.go index 11bfac75..85599c79 100644 --- a/api/api/errors.go +++ b/api/api/errors.go @@ -27,6 +27,7 @@ var ( ErrFailedToUpdateAccount = errors.New("failed to update account") ErrFailedToDeleteAccount = errors.New("failed to delete account") ErrFailedToCreateCustomRule = errors.New("failed to create custom rule") + ErrFailedToUpdateCustomRule = errors.New("failed to update custom rule") ErrFailedToDeleteCustomRule = errors.New("failed to delete custom rule") ErrFailedToEnableBlocklists = errors.New("failed to enable blocklists") ErrFailedToDisableBlocklists = errors.New("failed to disable blocklists") @@ -226,7 +227,7 @@ func HandleError(c *fiber.Ctx, err error, errMsg string, details ...string) erro case dbErrors.ErrAccountNotFound, account.ErrAccountIdMissing, dbErrors.ErrProfileNotFound, dbErrors.ErrCustomRuleNotFound, dbErrors.ErrSubscriptionNotFound: resp.Error = ErrResourceNotFound.Error() return c.Status(404).JSON(resp) - case ErrInvalidRequestBody, model.ErrInvalidCustomRuleAction, account.ErrEmailAlreadyVerified, account.ErrPasswordTooSimple, account.ErrEmailNotVerified, account.ErrInvalidVerificationToken, account.ErrTokenExpired, account.ErrPasswordsDoNotMatch, profile.ErrProfileNameAlreadyExists, model.ErrInvalidRetention, profile.ErrProfileNameCannotBeEmpty, profile.ErrDefaultRuleInvalid, profile.ErrBlocklistNotFound, profile.ErrProfileNameEmpty, profile.ErrCustomRuleAlreadyExists, ErrInvalidCustomRuleSyntax, profile.ErrLastProfileInAccount, profile.ErrMaxProfilesLimitReached, profile.ErrInvalidServiceValue, profile.ErrServiceAlreadyEnabled: + case ErrInvalidRequestBody, model.ErrInvalidCustomRuleAction, account.ErrEmailAlreadyVerified, account.ErrPasswordTooSimple, account.ErrEmailNotVerified, account.ErrInvalidVerificationToken, account.ErrTokenExpired, account.ErrPasswordsDoNotMatch, profile.ErrProfileNameAlreadyExists, model.ErrInvalidRetention, profile.ErrProfileNameCannotBeEmpty, profile.ErrDefaultRuleInvalid, profile.ErrBlocklistNotFound, profile.ErrProfileNameEmpty, profile.ErrCustomRuleAlreadyExists, ErrInvalidCustomRuleSyntax, model.ErrInvalidCustomRuleSyntax, profile.ErrLastProfileInAccount, profile.ErrMaxProfilesLimitReached, profile.ErrInvalidServiceValue, profile.ErrServiceAlreadyEnabled: resp.Error = badRequestErrorText(err, errMsg) return c.Status(400).JSON(resp) case subscription.ErrSubscriptionScheduledForDeletion: diff --git a/api/api/requests/profiles.go b/api/api/requests/profiles.go index d64b58cc..476eeb8e 100644 --- a/api/api/requests/profiles.go +++ b/api/api/requests/profiles.go @@ -14,6 +14,46 @@ type CreateProfileCustomRulesBatchBody struct { Values []string `json:"values" validate:"required,min=1,max=20,dive,required,ipv4|ipv6|fqdn|fqdn_wildcard|asn"` } +// UpdateProfileCustomRuleBody is the partial-update payload for a single custom +// rule. All fields are optional; a nil pointer leaves the field unchanged, while +// a present value (including an empty string) is applied. +type UpdateProfileCustomRuleBody struct { + Action *string `json:"action" validate:"omitempty,oneof=block allow comment"` + Value *string `json:"value" validate:"omitempty,ipv4|ipv6|fqdn|fqdn_wildcard|asn"` + Note *string `json:"note" validate:"omitempty,max=80"` + Group *string `json:"group" validate:"omitempty,max=64"` + Order *int `json:"order" validate:"omitempty,min=0"` +} + +// ReorderProfileCustomRulesBody carries the complete ordered list of rule IDs for +// a profile; the rule at index 0 sorts first. +type ReorderProfileCustomRulesBody struct { + Order []string `json:"order" validate:"required,min=1,max=10000,dive,required"` +} + +// CustomRuleGroupUpdate is a single JSON-Patch-style operation on the custom-rule +// group registry, mirroring the shape of model.ProfileUpdate. Group names travel +// in the JSON-Pointer `path`/`from` fields (RFC6901, `~1`=/, `~0`=~) so they never +// appear in the URL. Unlike ProfileUpdate, `path` is open (dynamic group names) and +// validated by format; the decoded name length is checked in the service. +// - add | replace : set/clear a group's note (value); creates the group. +// - remove : delete the group (member rules → Ungrouped, note dropped). +// - move : rename `from` → `path` (reassign member rules, move the note). +type CustomRuleGroupUpdate struct { + Operation string `json:"operation" validate:"required,oneof=add replace remove move"` + // Action scopes the op to one list ("block" = denylist, "allow" = allowlist); + // groups are per-list. + Action string `json:"action" validate:"required,oneof=block allow"` + Path string `json:"path" validate:"required,startswith=/,max=130"` + From string `json:"from" validate:"omitempty,startswith=/,max=130"` + Value *string `json:"value" validate:"omitempty,max=80"` +} + +// CustomRuleGroupUpdates is the body of PATCH /custom_rule_groups. +type CustomRuleGroupUpdates struct { + Updates []CustomRuleGroupUpdate `json:"updates" validate:"required,min=1,max=50,dive"` +} + type ProfileUpdates struct { Updates []model.ProfileUpdate `json:"updates" validate:"required,dive"` } diff --git a/api/api/server.go b/api/api/server.go index 3c05c3e8..32d49e80 100644 --- a/api/api/server.go +++ b/api/api/server.go @@ -261,6 +261,11 @@ func (s *APIServer) RegisterRoutes() { // Custom rules endpoints profiles.Delete("/:profile_id/custom_rules/:custom_rule_id", middleware.NewLimit(20, 1*time.Minute), s.deleteProfileCustomRule()) + // Register the literal "order" and group paths before the parameterized + // "/custom_rules/:custom_rule_id" PATCH so they are not shadowed by it. + profiles.Patch("/:id/custom_rules/order", middleware.NewLimit(20, 1*time.Minute), s.reorderProfileCustomRules()) + profiles.Patch("/:id/custom_rule_groups", middleware.NewLimit(20, 1*time.Minute), s.updateProfileCustomRuleGroups()) + profiles.Patch("/:profile_id/custom_rules/:custom_rule_id", middleware.NewLimit(20, 1*time.Minute), s.updateProfileCustomRule()) profiles.Post("/:id/custom_rules/batch", middleware.NewLimit(20, 1*time.Minute), s.createProfileCustomRulesBatch()) profiles.Post("/:id/custom_rules", middleware.NewLimit(20, 1*time.Minute), s.createProfileCustomRule()) diff --git a/api/db/mongodb/migrations/021_profiles_custom_rules_enrich.down.json b/api/db/mongodb/migrations/021_profiles_custom_rules_enrich.down.json new file mode 100644 index 00000000..5322a569 --- /dev/null +++ b/api/db/mongodb/migrations/021_profiles_custom_rules_enrich.down.json @@ -0,0 +1,20 @@ +[ + { + "update": "profiles", + "updates": [ + { + "q": {}, + "u": { + "$unset": { + "settings.custom_rules.$[].note": "", + "settings.custom_rules.$[].group": "", + "settings.custom_rules.$[].order": "", + "settings.custom_rule_groups": "" + } + }, + "multi": true + } + ], + "writeConcern": { "w": "majority" } + } +] diff --git a/api/db/mongodb/migrations/021_profiles_custom_rules_enrich.up.json b/api/db/mongodb/migrations/021_profiles_custom_rules_enrich.up.json new file mode 100644 index 00000000..eac60075 --- /dev/null +++ b/api/db/mongodb/migrations/021_profiles_custom_rules_enrich.up.json @@ -0,0 +1,39 @@ +[ + { + "update": "profiles", + "updates": [ + { + "q": {}, + "u": [ + { + "$set": { + "settings.custom_rules": { + "$map": { + "input": { + "$range": [ + 0, + { "$size": { "$ifNull": ["$settings.custom_rules", []] } } + ] + }, + "as": "idx", + "in": { + "$mergeObjects": [ + { "note": "", "group": "" }, + { "$arrayElemAt": ["$settings.custom_rules", "$$idx"] }, + { "order": "$$idx" } + ] + } + } + }, + "settings.custom_rule_groups": { + "$ifNull": ["$settings.custom_rule_groups", {}] + } + } + } + ], + "multi": true + } + ], + "writeConcern": { "w": "majority" } + } +] diff --git a/api/db/mongodb/profile.go b/api/db/mongodb/profile.go index d6c792a5..330b70ff 100644 --- a/api/db/mongodb/profile.go +++ b/api/db/mongodb/profile.go @@ -2,6 +2,7 @@ package mongodb import ( "context" + "fmt" "github.com/ivpn/dns/api/db/errors" "github.com/ivpn/dns/api/model" @@ -9,6 +10,7 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" ) // ProfileRepository is a MongoDB repository for profiles collection @@ -147,6 +149,100 @@ func (r *ProfileRepository) CreateCustomRules(ctx context.Context, profileId str return nil } +// UpdateCustomRule updates a single custom rule in place, matched by its ObjectID, +// preserving the rule's position in the settings.custom_rules array. +func (r *ProfileRepository) UpdateCustomRule(ctx context.Context, profileId string, rule *model.CustomRule) error { + filterBson := bson.D{ + {Key: "profile_id", Value: profileId}, + {Key: "settings.custom_rules._id", Value: rule.ID}, + } + updateBson := bson.D{ + {Key: "$set", Value: bson.D{ + {Key: "settings.custom_rules.$.action", Value: rule.Action}, + {Key: "settings.custom_rules.$.value", Value: rule.Value}, + {Key: "settings.custom_rules.$.syntax", Value: rule.Syntax}, + {Key: "settings.custom_rules.$.note", Value: rule.Note}, + {Key: "settings.custom_rules.$.group", Value: rule.Group}, + {Key: "settings.custom_rules.$.order", Value: rule.Order}, + }}, + } + + res, err := r.profilesCollection.UpdateOne(ctx, filterBson, updateBson) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return errors.ErrCustomRuleNotFound + } + return nil +} + +// UpdateCustomRulesOrder sets the display `order` of each rule identified by its +// hex ObjectID, in a single atomic update using arrayFilters. Rules not present +// in idToOrder keep their stored order. +func (r *ProfileRepository) UpdateCustomRulesOrder(ctx context.Context, profileId string, idToOrder map[string]int) error { + if len(idToOrder) == 0 { + return nil + } + + setDoc := bson.D{} + arrayFilters := make([]any, 0, len(idToOrder)) + i := 0 + for id, order := range idToOrder { + objectID, err := primitive.ObjectIDFromHex(id) + if err != nil { + return err + } + identifier := fmt.Sprintf("r%d", i) + setDoc = append(setDoc, bson.E{ + Key: fmt.Sprintf("settings.custom_rules.$[%s].order", identifier), + Value: order, + }) + arrayFilters = append(arrayFilters, bson.M{identifier + "._id": objectID}) + i++ + } + + filterBson := bson.D{{Key: "profile_id", Value: profileId}} + updateBson := bson.D{{Key: "$set", Value: setDoc}} + opts := options.Update().SetArrayFilters(options.ArrayFilters{Filters: arrayFilters}) + + _, err := r.profilesCollection.UpdateOne(ctx, filterBson, updateBson, opts) + return err +} + +// SetCustomRuleGroups replaces the profile's per-list group registry wholesale. +// The registry is metadata only and is never synced to the proxy. +func (r *ProfileRepository) SetCustomRuleGroups(ctx context.Context, profileId string, groups model.CustomRuleGroups) error { + filterBson := bson.D{{Key: "profile_id", Value: profileId}} + updateBson := bson.D{ + {Key: "$set", Value: bson.D{ + {Key: "settings.custom_rule_groups", Value: groups}, + }}, + } + + _, err := r.profilesCollection.UpdateOne(ctx, filterBson, updateBson) + return err +} + +// ReassignCustomRuleGroup sets group=to on every rule with the given action whose +// group==from, in a single atomic update using an arrayFilter. Scoping by action +// keeps denylist and allowlist groups independent. Passing to="" moves the rules +// to Ungrouped (used by group deletion). Group labels are metadata only. +func (r *ProfileRepository) ReassignCustomRuleGroup(ctx context.Context, profileId, action, from, to string) error { + filterBson := bson.D{{Key: "profile_id", Value: profileId}} + updateBson := bson.D{ + {Key: "$set", Value: bson.D{ + {Key: "settings.custom_rules.$[elem].group", Value: to}, + }}, + } + opts := options.Update().SetArrayFilters(options.ArrayFilters{ + Filters: []any{bson.M{"elem.group": from, "elem.action": action}}, + }) + + _, err := r.profilesCollection.UpdateOne(ctx, filterBson, updateBson, opts) + return err +} + // EnableBlocklists adds the given blocklist IDs to the profile's enabled blocklists array atomically. func (r *ProfileRepository) EnableBlocklists(ctx context.Context, profileId string, blocklistIds []string) error { filterBson := bson.D{primitive.E{Key: "profile_id", Value: profileId}} diff --git a/api/db/repository/profile.go b/api/db/repository/profile.go index a1c3ab2b..898e675b 100644 --- a/api/db/repository/profile.go +++ b/api/db/repository/profile.go @@ -11,6 +11,10 @@ type ProfileRepository interface { CreateProfile(ctx context.Context, profile *model.Profile) error CreateCustomRules(ctx context.Context, profileId string, rules []*model.CustomRule) error RemoveCustomRules(ctx context.Context, profileId string, ruleIds []string) error + UpdateCustomRule(ctx context.Context, profileId string, rule *model.CustomRule) error + UpdateCustomRulesOrder(ctx context.Context, profileId string, idToOrder map[string]int) error + SetCustomRuleGroups(ctx context.Context, profileId string, groups model.CustomRuleGroups) error + ReassignCustomRuleGroup(ctx context.Context, profileId, action, from, to string) error EnableBlocklists(ctx context.Context, profileId string, blocklistIds []string) error DisableBlocklists(ctx context.Context, profileId string, blocklistIds []string) error EnableServices(ctx context.Context, profileId string, serviceIds []string) error diff --git a/api/docs/docs.go b/api/docs/docs.go index a1554075..fea93038 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1349,6 +1349,61 @@ const docTemplate = `{ } } }, + "/api/v1/profiles/{id}/custom_rule_groups": { + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Apply JSON-Patch-style operations to the custom-rule group registry. Group names travel in the JSON-Pointer path/from (never the URL). operation=add|replace sets a group's note (creating it); remove deletes a group (its rules move to Ungrouped, not deleted); move renames from-\u003epath.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Update profile custom rule groups", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Group operations", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.CustomRuleGroupUpdates" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/profiles/{id}/custom_rules": { "post": { "security": [ @@ -1462,6 +1517,67 @@ const docTemplate = `{ } } }, + "/api/v1/profiles/{id}/custom_rules/order": { + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Set the display order of a profile's custom rules. Order is organizational only and does not affect filtering precedence.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Reorder profile custom rules", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Ordered rule IDs", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.ReorderProfileCustomRulesBody" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/profiles/{id}/custom_rules/{custom_rule_id}": { "delete": { "security": [ @@ -1882,6 +1998,77 @@ const docTemplate = `{ } } }, + "/api/v1/profiles/{profile_id}/custom_rules/{custom_rule_id}": { + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Partially update a single custom rule in place (value, action, note, group, order)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Update profile custom rule", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "profile_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Custom rule ID", + "name": "custom_rule_id", + "in": "path", + "required": true + }, + { + "description": "Update custom rule request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UpdateProfileCustomRuleBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.CustomRule" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/services": { "get": { "security": [ @@ -2947,14 +3134,58 @@ const docTemplate = `{ "action": { "type": "string" }, + "group": { + "type": "string" + }, "id": { "type": "string" }, + "note": { + "type": "string" + }, + "order": { + "type": "integer" + }, "value": { "type": "string" } } }, + "model.CustomRuleGroup": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "comment": { + "type": "string", + "maxLength": 80 + }, + "name": { + "type": "string", + "maxLength": 64 + } + } + }, + "model.CustomRuleGroups": { + "type": "object", + "properties": { + "allow": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/definitions/model.CustomRuleGroup" + } + }, + "block": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/definitions/model.CustomRuleGroup" + } + } + } + }, "model.DNSRequest": { "type": "object", "properties": { @@ -3045,9 +3276,15 @@ const docTemplate = `{ "comment" ] }, - "comment": { + "group": { + "description": "Group is the optional organizational label this rule belongs to.", "type": "string", - "maxLength": 200 + "maxLength": 64 + }, + "note": { + "description": "Note is a free-text annotation. Free text (not safe_name) so users can write\narbitrary reminders; length-capped to match the model/PATCH validators.", + "type": "string", + "maxLength": 80 }, "value": { "type": "string", @@ -3180,6 +3417,14 @@ const docTemplate = `{ "advanced": { "$ref": "#/definitions/model.ExportedAdvanced" }, + "customRuleGroups": { + "description": "CustomRuleGroups is the per-list group registry; reuses the storage type\n(its json tags define the wire shape). Pointer so an empty registry is\nomitted. Round-trips with the rules' ` + "`" + `group` + "`" + ` field.", + "allOf": [ + { + "$ref": "#/definitions/model.CustomRuleGroups" + } + ] + }, "customRules": { "description": "CustomRules holds the profile's custom filtering rules, capped at 1000 per profile.", "type": "array", @@ -3325,6 +3570,14 @@ const docTemplate = `{ "advanced": { "$ref": "#/definitions/model.Advanced" }, + "custom_rule_groups": { + "description": "CustomRuleGroups is the per-list group registry (denylist/allowlist).\nOrganizational metadata only; never synced to the proxy (redis:\"-\").", + "allOf": [ + { + "$ref": "#/definitions/model.CustomRuleGroups" + } + ] + }, "custom_rules": { "type": "array", "items": { @@ -4042,6 +4295,61 @@ const docTemplate = `{ } } }, + "requests.CustomRuleGroupUpdate": { + "type": "object", + "required": [ + "action", + "operation", + "path" + ], + "properties": { + "action": { + "description": "Action scopes the op to one list (\"block\" = denylist, \"allow\" = allowlist);\ngroups are per-list.", + "type": "string", + "enum": [ + "block", + "allow" + ] + }, + "from": { + "type": "string", + "maxLength": 130 + }, + "operation": { + "type": "string", + "enum": [ + "add", + "replace", + "remove", + "move" + ] + }, + "path": { + "type": "string", + "maxLength": 130 + }, + "value": { + "type": "string", + "maxLength": 80 + } + } + }, + "requests.CustomRuleGroupUpdates": { + "type": "object", + "required": [ + "updates" + ], + "properties": { + "updates": { + "type": "array", + "maxItems": 50, + "minItems": 1, + "items": { + "$ref": "#/definitions/requests.CustomRuleGroupUpdate" + } + } + } + }, "requests.ExportRequest": { "type": "object", "required": [ @@ -4163,6 +4471,22 @@ const docTemplate = `{ } } }, + "requests.ReorderProfileCustomRulesBody": { + "type": "object", + "required": [ + "order" + ], + "properties": { + "order": { + "type": "array", + "maxItems": 10000, + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, "requests.ResetPasswordBody": { "type": "object", "required": [ @@ -4198,6 +4522,34 @@ const docTemplate = `{ } } }, + "requests.UpdateProfileCustomRuleBody": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "block", + "allow", + "comment" + ] + }, + "group": { + "type": "string", + "maxLength": 64 + }, + "note": { + "type": "string", + "maxLength": 80 + }, + "order": { + "type": "integer", + "minimum": 0 + }, + "value": { + "type": "string" + } + } + }, "requests.WebAuthnReauthBeginRequest": { "type": "object", "required": [ diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 7199a8af..276d1eb3 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1341,6 +1341,61 @@ } } }, + "/api/v1/profiles/{id}/custom_rule_groups": { + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Apply JSON-Patch-style operations to the custom-rule group registry. Group names travel in the JSON-Pointer path/from (never the URL). operation=add|replace sets a group's note (creating it); remove deletes a group (its rules move to Ungrouped, not deleted); move renames from-\u003epath.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Update profile custom rule groups", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Group operations", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.CustomRuleGroupUpdates" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/profiles/{id}/custom_rules": { "post": { "security": [ @@ -1454,6 +1509,67 @@ } } }, + "/api/v1/profiles/{id}/custom_rules/order": { + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Set the display order of a profile's custom rules. Order is organizational only and does not affect filtering precedence.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Reorder profile custom rules", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Ordered rule IDs", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.ReorderProfileCustomRulesBody" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/profiles/{id}/custom_rules/{custom_rule_id}": { "delete": { "security": [ @@ -1874,6 +1990,77 @@ } } }, + "/api/v1/profiles/{profile_id}/custom_rules/{custom_rule_id}": { + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Partially update a single custom rule in place (value, action, note, group, order)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Update profile custom rule", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "profile_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Custom rule ID", + "name": "custom_rule_id", + "in": "path", + "required": true + }, + { + "description": "Update custom rule request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UpdateProfileCustomRuleBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.CustomRule" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/services": { "get": { "security": [ @@ -2939,14 +3126,58 @@ "action": { "type": "string" }, + "group": { + "type": "string" + }, "id": { "type": "string" }, + "note": { + "type": "string" + }, + "order": { + "type": "integer" + }, "value": { "type": "string" } } }, + "model.CustomRuleGroup": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "comment": { + "type": "string", + "maxLength": 80 + }, + "name": { + "type": "string", + "maxLength": 64 + } + } + }, + "model.CustomRuleGroups": { + "type": "object", + "properties": { + "allow": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/definitions/model.CustomRuleGroup" + } + }, + "block": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/definitions/model.CustomRuleGroup" + } + } + } + }, "model.DNSRequest": { "type": "object", "properties": { @@ -3037,9 +3268,15 @@ "comment" ] }, - "comment": { + "group": { + "description": "Group is the optional organizational label this rule belongs to.", "type": "string", - "maxLength": 200 + "maxLength": 64 + }, + "note": { + "description": "Note is a free-text annotation. Free text (not safe_name) so users can write\narbitrary reminders; length-capped to match the model/PATCH validators.", + "type": "string", + "maxLength": 80 }, "value": { "type": "string", @@ -3172,6 +3409,14 @@ "advanced": { "$ref": "#/definitions/model.ExportedAdvanced" }, + "customRuleGroups": { + "description": "CustomRuleGroups is the per-list group registry; reuses the storage type\n(its json tags define the wire shape). Pointer so an empty registry is\nomitted. Round-trips with the rules' `group` field.", + "allOf": [ + { + "$ref": "#/definitions/model.CustomRuleGroups" + } + ] + }, "customRules": { "description": "CustomRules holds the profile's custom filtering rules, capped at 1000 per profile.", "type": "array", @@ -3317,6 +3562,14 @@ "advanced": { "$ref": "#/definitions/model.Advanced" }, + "custom_rule_groups": { + "description": "CustomRuleGroups is the per-list group registry (denylist/allowlist).\nOrganizational metadata only; never synced to the proxy (redis:\"-\").", + "allOf": [ + { + "$ref": "#/definitions/model.CustomRuleGroups" + } + ] + }, "custom_rules": { "type": "array", "items": { @@ -4034,6 +4287,61 @@ } } }, + "requests.CustomRuleGroupUpdate": { + "type": "object", + "required": [ + "action", + "operation", + "path" + ], + "properties": { + "action": { + "description": "Action scopes the op to one list (\"block\" = denylist, \"allow\" = allowlist);\ngroups are per-list.", + "type": "string", + "enum": [ + "block", + "allow" + ] + }, + "from": { + "type": "string", + "maxLength": 130 + }, + "operation": { + "type": "string", + "enum": [ + "add", + "replace", + "remove", + "move" + ] + }, + "path": { + "type": "string", + "maxLength": 130 + }, + "value": { + "type": "string", + "maxLength": 80 + } + } + }, + "requests.CustomRuleGroupUpdates": { + "type": "object", + "required": [ + "updates" + ], + "properties": { + "updates": { + "type": "array", + "maxItems": 50, + "minItems": 1, + "items": { + "$ref": "#/definitions/requests.CustomRuleGroupUpdate" + } + } + } + }, "requests.ExportRequest": { "type": "object", "required": [ @@ -4155,6 +4463,22 @@ } } }, + "requests.ReorderProfileCustomRulesBody": { + "type": "object", + "required": [ + "order" + ], + "properties": { + "order": { + "type": "array", + "maxItems": 10000, + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, "requests.ResetPasswordBody": { "type": "object", "required": [ @@ -4190,6 +4514,34 @@ } } }, + "requests.UpdateProfileCustomRuleBody": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "block", + "allow", + "comment" + ] + }, + "group": { + "type": "string", + "maxLength": 64 + }, + "note": { + "type": "string", + "maxLength": 80 + }, + "order": { + "type": "integer", + "minimum": 0 + }, + "value": { + "type": "string" + } + } + }, "requests.WebAuthnReauthBeginRequest": { "type": "object", "required": [ diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index efbe3e2a..14877971 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -240,8 +240,14 @@ definitions: properties: action: type: string + group: + type: string id: type: string + note: + type: string + order: + type: integer value: type: string required: @@ -249,6 +255,30 @@ definitions: - id - value type: object + model.CustomRuleGroup: + properties: + comment: + maxLength: 80 + type: string + name: + maxLength: 64 + type: string + required: + - name + type: object + model.CustomRuleGroups: + properties: + allow: + items: + $ref: '#/definitions/model.CustomRuleGroup' + maxItems: 100 + type: array + block: + items: + $ref: '#/definitions/model.CustomRuleGroup' + maxItems: 100 + type: array + type: object model.DNSRequest: properties: dnssec: @@ -308,8 +338,16 @@ definitions: - allow - comment type: string - comment: - maxLength: 200 + group: + description: Group is the optional organizational label this rule belongs + to. + maxLength: 64 + type: string + note: + description: |- + Note is a free-text annotation. Free text (not safe_name) so users can write + arbitrary reminders; length-capped to match the model/PATCH validators. + maxLength: 80 type: string value: maxLength: 255 @@ -407,6 +445,13 @@ definitions: properties: advanced: $ref: '#/definitions/model.ExportedAdvanced' + customRuleGroups: + allOf: + - $ref: '#/definitions/model.CustomRuleGroups' + description: |- + CustomRuleGroups is the per-list group registry; reuses the storage type + (its json tags define the wire shape). Pointer so an empty registry is + omitted. Round-trips with the rules' `group` field. customRules: description: CustomRules holds the profile's custom filtering rules, capped at 1000 per profile. @@ -502,6 +547,12 @@ definitions: properties: advanced: $ref: '#/definitions/model.Advanced' + custom_rule_groups: + allOf: + - $ref: '#/definitions/model.CustomRuleGroups' + description: |- + CustomRuleGroups is the per-list group registry (denylist/allowlist). + Organizational metadata only; never synced to the proxy (redis:"-"). custom_rules: items: $ref: '#/definitions/model.CustomRule' @@ -1051,6 +1102,48 @@ definitions: - action - values type: object + requests.CustomRuleGroupUpdate: + properties: + action: + description: |- + Action scopes the op to one list ("block" = denylist, "allow" = allowlist); + groups are per-list. + enum: + - block + - allow + type: string + from: + maxLength: 130 + type: string + operation: + enum: + - add + - replace + - remove + - move + type: string + path: + maxLength: 130 + type: string + value: + maxLength: 80 + type: string + required: + - action + - operation + - path + type: object + requests.CustomRuleGroupUpdates: + properties: + updates: + items: + $ref: '#/definitions/requests.CustomRuleGroupUpdate' + maxItems: 50 + minItems: 1 + type: array + required: + - updates + type: object requests.ExportRequest: properties: current_password: @@ -1138,6 +1231,17 @@ definitions: required: - updates type: object + requests.ReorderProfileCustomRulesBody: + properties: + order: + items: + type: string + maxItems: 10000 + minItems: 1 + type: array + required: + - order + type: object requests.ResetPasswordBody: properties: email: @@ -1161,6 +1265,26 @@ definitions: required: - otp type: object + requests.UpdateProfileCustomRuleBody: + properties: + action: + enum: + - block + - allow + - comment + type: string + group: + maxLength: 64 + type: string + note: + maxLength: 80 + type: string + order: + minimum: 0 + type: integer + value: + type: string + type: object requests.WebAuthnReauthBeginRequest: properties: purpose: @@ -2052,6 +2176,44 @@ paths: summary: Enable blocklists tags: - Profile + /api/v1/profiles/{id}/custom_rule_groups: + patch: + consumes: + - application/json + description: Apply JSON-Patch-style operations to the custom-rule group registry. + Group names travel in the JSON-Pointer path/from (never the URL). operation=add|replace + sets a group's note (creating it); remove deletes a group (its rules move + to Ungrouped, not deleted); move renames from->path. + parameters: + - description: Profile ID + in: path + name: id + required: true + type: string + - description: Group operations + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.CustomRuleGroupUpdates' + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrResponse' + security: + - ApiKeyAuth: [] + summary: Update profile custom rule groups + tags: + - Profile /api/v1/profiles/{id}/custom_rules: post: consumes: @@ -2160,6 +2322,46 @@ paths: summary: Create profile custom rules (batch) tags: - Profile + /api/v1/profiles/{id}/custom_rules/order: + patch: + consumes: + - application/json + description: Set the display order of a profile's custom rules. Order is organizational + only and does not affect filtering precedence. + parameters: + - description: Profile ID + in: path + name: id + required: true + type: string + - description: Ordered rule IDs + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.ReorderProfileCustomRulesBody' + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.ErrResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrResponse' + security: + - ApiKeyAuth: [] + summary: Reorder profile custom rules + tags: + - Profile /api/v1/profiles/{id}/logs: delete: description: Delete profile query logs @@ -2397,6 +2599,53 @@ paths: summary: Get statistics data for a profile tags: - Statistics + /api/v1/profiles/{profile_id}/custom_rules/{custom_rule_id}: + patch: + consumes: + - application/json + description: Partially update a single custom rule in place (value, action, + note, group, order) + parameters: + - description: Profile ID + in: path + name: profile_id + required: true + type: string + - description: Custom rule ID + in: path + name: custom_rule_id + required: true + type: string + - description: Update custom rule request + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.UpdateProfileCustomRuleBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.CustomRule' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.ErrResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrResponse' + security: + - ApiKeyAuth: [] + summary: Update profile custom rule + tags: + - Profile /api/v1/profiles/export: post: consumes: diff --git a/api/mocks/db.go b/api/mocks/db.go index 593d1897..23d651a4 100644 --- a/api/mocks/db.go +++ b/api/mocks/db.go @@ -3001,6 +3001,81 @@ func (_c *Db_Migrate_Call) RunAndReturn(run func() error) *Db_Migrate_Call { return _c } +// ReassignCustomRuleGroup provides a mock function for the type Db +func (_mock *Db) ReassignCustomRuleGroup(ctx context.Context, profileId string, action string, from string, to string) error { + ret := _mock.Called(ctx, profileId, action, from, to) + + if len(ret) == 0 { + panic("no return value specified for ReassignCustomRuleGroup") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) error); ok { + r0 = returnFunc(ctx, profileId, action, from, to) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Db_ReassignCustomRuleGroup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReassignCustomRuleGroup' +type Db_ReassignCustomRuleGroup_Call struct { + *mock.Call +} + +// ReassignCustomRuleGroup is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - action string +// - from string +// - to string +func (_e *Db_Expecter) ReassignCustomRuleGroup(ctx interface{}, profileId interface{}, action interface{}, from interface{}, to interface{}) *Db_ReassignCustomRuleGroup_Call { + return &Db_ReassignCustomRuleGroup_Call{Call: _e.mock.On("ReassignCustomRuleGroup", ctx, profileId, action, from, to)} +} + +func (_c *Db_ReassignCustomRuleGroup_Call) Run(run func(ctx context.Context, profileId string, action string, from string, to string)) *Db_ReassignCustomRuleGroup_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *Db_ReassignCustomRuleGroup_Call) Return(err error) *Db_ReassignCustomRuleGroup_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Db_ReassignCustomRuleGroup_Call) RunAndReturn(run func(ctx context.Context, profileId string, action string, from string, to string) error) *Db_ReassignCustomRuleGroup_Call { + _c.Call.Return(run) + return _c +} + // RemoveCustomRules provides a mock function for the type Db func (_mock *Db) RemoveCustomRules(ctx context.Context, profileId string, ruleIds []string) error { ret := _mock.Called(ctx, profileId, ruleIds) @@ -3271,6 +3346,69 @@ func (_c *Db_SaveSession_Call) RunAndReturn(run func(ctx context.Context, sessio return _c } +// SetCustomRuleGroups provides a mock function for the type Db +func (_mock *Db) SetCustomRuleGroups(ctx context.Context, profileId string, groups model.CustomRuleGroups) error { + ret := _mock.Called(ctx, profileId, groups) + + if len(ret) == 0 { + panic("no return value specified for SetCustomRuleGroups") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, model.CustomRuleGroups) error); ok { + r0 = returnFunc(ctx, profileId, groups) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Db_SetCustomRuleGroups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetCustomRuleGroups' +type Db_SetCustomRuleGroups_Call struct { + *mock.Call +} + +// SetCustomRuleGroups is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - groups model.CustomRuleGroups +func (_e *Db_Expecter) SetCustomRuleGroups(ctx interface{}, profileId interface{}, groups interface{}) *Db_SetCustomRuleGroups_Call { + return &Db_SetCustomRuleGroups_Call{Call: _e.mock.On("SetCustomRuleGroups", ctx, profileId, groups)} +} + +func (_c *Db_SetCustomRuleGroups_Call) Run(run func(ctx context.Context, profileId string, groups model.CustomRuleGroups)) *Db_SetCustomRuleGroups_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 model.CustomRuleGroups + if args[2] != nil { + arg2 = args[2].(model.CustomRuleGroups) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *Db_SetCustomRuleGroups_Call) Return(err error) *Db_SetCustomRuleGroups_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Db_SetCustomRuleGroups_Call) RunAndReturn(run func(ctx context.Context, profileId string, groups model.CustomRuleGroups) error) *Db_SetCustomRuleGroups_Call { + _c.Call.Return(run) + return _c +} + // SetInactiveNotified provides a mock function for the type Db func (_mock *Db) SetInactiveNotified(ctx context.Context, subscriptionIDs []uuid.UUID, value bool) error { ret := _mock.Called(ctx, subscriptionIDs, value) @@ -3591,6 +3729,132 @@ func (_c *Db_UpdateCredential_Call) RunAndReturn(run func(ctx context.Context, c return _c } +// UpdateCustomRule provides a mock function for the type Db +func (_mock *Db) UpdateCustomRule(ctx context.Context, profileId string, rule *model.CustomRule) error { + ret := _mock.Called(ctx, profileId, rule) + + if len(ret) == 0 { + panic("no return value specified for UpdateCustomRule") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, *model.CustomRule) error); ok { + r0 = returnFunc(ctx, profileId, rule) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Db_UpdateCustomRule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateCustomRule' +type Db_UpdateCustomRule_Call struct { + *mock.Call +} + +// UpdateCustomRule is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - rule *model.CustomRule +func (_e *Db_Expecter) UpdateCustomRule(ctx interface{}, profileId interface{}, rule interface{}) *Db_UpdateCustomRule_Call { + return &Db_UpdateCustomRule_Call{Call: _e.mock.On("UpdateCustomRule", ctx, profileId, rule)} +} + +func (_c *Db_UpdateCustomRule_Call) Run(run func(ctx context.Context, profileId string, rule *model.CustomRule)) *Db_UpdateCustomRule_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 *model.CustomRule + if args[2] != nil { + arg2 = args[2].(*model.CustomRule) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *Db_UpdateCustomRule_Call) Return(err error) *Db_UpdateCustomRule_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Db_UpdateCustomRule_Call) RunAndReturn(run func(ctx context.Context, profileId string, rule *model.CustomRule) error) *Db_UpdateCustomRule_Call { + _c.Call.Return(run) + return _c +} + +// UpdateCustomRulesOrder provides a mock function for the type Db +func (_mock *Db) UpdateCustomRulesOrder(ctx context.Context, profileId string, idToOrder map[string]int) error { + ret := _mock.Called(ctx, profileId, idToOrder) + + if len(ret) == 0 { + panic("no return value specified for UpdateCustomRulesOrder") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]int) error); ok { + r0 = returnFunc(ctx, profileId, idToOrder) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Db_UpdateCustomRulesOrder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateCustomRulesOrder' +type Db_UpdateCustomRulesOrder_Call struct { + *mock.Call +} + +// UpdateCustomRulesOrder is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - idToOrder map[string]int +func (_e *Db_Expecter) UpdateCustomRulesOrder(ctx interface{}, profileId interface{}, idToOrder interface{}) *Db_UpdateCustomRulesOrder_Call { + return &Db_UpdateCustomRulesOrder_Call{Call: _e.mock.On("UpdateCustomRulesOrder", ctx, profileId, idToOrder)} +} + +func (_c *Db_UpdateCustomRulesOrder_Call) Run(run func(ctx context.Context, profileId string, idToOrder map[string]int)) *Db_UpdateCustomRulesOrder_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 map[string]int + if args[2] != nil { + arg2 = args[2].(map[string]int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *Db_UpdateCustomRulesOrder_Call) Return(err error) *Db_UpdateCustomRulesOrder_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Db_UpdateCustomRulesOrder_Call) RunAndReturn(run func(ctx context.Context, profileId string, idToOrder map[string]int) error) *Db_UpdateCustomRulesOrder_Call { + _c.Call.Return(run) + return _c +} + // UpdateDeletionCode provides a mock function for the type Db func (_mock *Db) UpdateDeletionCode(ctx context.Context, accountId string, code string, expiresAt time.Time) error { ret := _mock.Called(ctx, accountId, code, expiresAt) diff --git a/api/mocks/profile_repository.go b/api/mocks/profile_repository.go index 8845772b..3a398205 100644 --- a/api/mocks/profile_repository.go +++ b/api/mocks/profile_repository.go @@ -603,6 +603,81 @@ func (_c *ProfileRepository_GetProfilesByAccountId_Call) RunAndReturn(run func(c return _c } +// ReassignCustomRuleGroup provides a mock function for the type ProfileRepository +func (_mock *ProfileRepository) ReassignCustomRuleGroup(ctx context.Context, profileId string, action string, from string, to string) error { + ret := _mock.Called(ctx, profileId, action, from, to) + + if len(ret) == 0 { + panic("no return value specified for ReassignCustomRuleGroup") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, string) error); ok { + r0 = returnFunc(ctx, profileId, action, from, to) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// ProfileRepository_ReassignCustomRuleGroup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReassignCustomRuleGroup' +type ProfileRepository_ReassignCustomRuleGroup_Call struct { + *mock.Call +} + +// ReassignCustomRuleGroup is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - action string +// - from string +// - to string +func (_e *ProfileRepository_Expecter) ReassignCustomRuleGroup(ctx interface{}, profileId interface{}, action interface{}, from interface{}, to interface{}) *ProfileRepository_ReassignCustomRuleGroup_Call { + return &ProfileRepository_ReassignCustomRuleGroup_Call{Call: _e.mock.On("ReassignCustomRuleGroup", ctx, profileId, action, from, to)} +} + +func (_c *ProfileRepository_ReassignCustomRuleGroup_Call) Run(run func(ctx context.Context, profileId string, action string, from string, to string)) *ProfileRepository_ReassignCustomRuleGroup_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 string + if args[4] != nil { + arg4 = args[4].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *ProfileRepository_ReassignCustomRuleGroup_Call) Return(err error) *ProfileRepository_ReassignCustomRuleGroup_Call { + _c.Call.Return(err) + return _c +} + +func (_c *ProfileRepository_ReassignCustomRuleGroup_Call) RunAndReturn(run func(ctx context.Context, profileId string, action string, from string, to string) error) *ProfileRepository_ReassignCustomRuleGroup_Call { + _c.Call.Return(run) + return _c +} + // RemoveCustomRules provides a mock function for the type ProfileRepository func (_mock *ProfileRepository) RemoveCustomRules(ctx context.Context, profileId string, ruleIds []string) error { ret := _mock.Called(ctx, profileId, ruleIds) @@ -666,6 +741,69 @@ func (_c *ProfileRepository_RemoveCustomRules_Call) RunAndReturn(run func(ctx co return _c } +// SetCustomRuleGroups provides a mock function for the type ProfileRepository +func (_mock *ProfileRepository) SetCustomRuleGroups(ctx context.Context, profileId string, groups model.CustomRuleGroups) error { + ret := _mock.Called(ctx, profileId, groups) + + if len(ret) == 0 { + panic("no return value specified for SetCustomRuleGroups") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, model.CustomRuleGroups) error); ok { + r0 = returnFunc(ctx, profileId, groups) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// ProfileRepository_SetCustomRuleGroups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetCustomRuleGroups' +type ProfileRepository_SetCustomRuleGroups_Call struct { + *mock.Call +} + +// SetCustomRuleGroups is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - groups model.CustomRuleGroups +func (_e *ProfileRepository_Expecter) SetCustomRuleGroups(ctx interface{}, profileId interface{}, groups interface{}) *ProfileRepository_SetCustomRuleGroups_Call { + return &ProfileRepository_SetCustomRuleGroups_Call{Call: _e.mock.On("SetCustomRuleGroups", ctx, profileId, groups)} +} + +func (_c *ProfileRepository_SetCustomRuleGroups_Call) Run(run func(ctx context.Context, profileId string, groups model.CustomRuleGroups)) *ProfileRepository_SetCustomRuleGroups_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 model.CustomRuleGroups + if args[2] != nil { + arg2 = args[2].(model.CustomRuleGroups) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ProfileRepository_SetCustomRuleGroups_Call) Return(err error) *ProfileRepository_SetCustomRuleGroups_Call { + _c.Call.Return(err) + return _c +} + +func (_c *ProfileRepository_SetCustomRuleGroups_Call) RunAndReturn(run func(ctx context.Context, profileId string, groups model.CustomRuleGroups) error) *ProfileRepository_SetCustomRuleGroups_Call { + _c.Call.Return(run) + return _c +} + // Update provides a mock function for the type ProfileRepository func (_mock *ProfileRepository) Update(ctx context.Context, profileId string, profile *model.Profile) error { ret := _mock.Called(ctx, profileId, profile) @@ -729,6 +867,132 @@ func (_c *ProfileRepository_Update_Call) RunAndReturn(run func(ctx context.Conte return _c } +// UpdateCustomRule provides a mock function for the type ProfileRepository +func (_mock *ProfileRepository) UpdateCustomRule(ctx context.Context, profileId string, rule *model.CustomRule) error { + ret := _mock.Called(ctx, profileId, rule) + + if len(ret) == 0 { + panic("no return value specified for UpdateCustomRule") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, *model.CustomRule) error); ok { + r0 = returnFunc(ctx, profileId, rule) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// ProfileRepository_UpdateCustomRule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateCustomRule' +type ProfileRepository_UpdateCustomRule_Call struct { + *mock.Call +} + +// UpdateCustomRule is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - rule *model.CustomRule +func (_e *ProfileRepository_Expecter) UpdateCustomRule(ctx interface{}, profileId interface{}, rule interface{}) *ProfileRepository_UpdateCustomRule_Call { + return &ProfileRepository_UpdateCustomRule_Call{Call: _e.mock.On("UpdateCustomRule", ctx, profileId, rule)} +} + +func (_c *ProfileRepository_UpdateCustomRule_Call) Run(run func(ctx context.Context, profileId string, rule *model.CustomRule)) *ProfileRepository_UpdateCustomRule_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 *model.CustomRule + if args[2] != nil { + arg2 = args[2].(*model.CustomRule) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ProfileRepository_UpdateCustomRule_Call) Return(err error) *ProfileRepository_UpdateCustomRule_Call { + _c.Call.Return(err) + return _c +} + +func (_c *ProfileRepository_UpdateCustomRule_Call) RunAndReturn(run func(ctx context.Context, profileId string, rule *model.CustomRule) error) *ProfileRepository_UpdateCustomRule_Call { + _c.Call.Return(run) + return _c +} + +// UpdateCustomRulesOrder provides a mock function for the type ProfileRepository +func (_mock *ProfileRepository) UpdateCustomRulesOrder(ctx context.Context, profileId string, idToOrder map[string]int) error { + ret := _mock.Called(ctx, profileId, idToOrder) + + if len(ret) == 0 { + panic("no return value specified for UpdateCustomRulesOrder") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]int) error); ok { + r0 = returnFunc(ctx, profileId, idToOrder) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// ProfileRepository_UpdateCustomRulesOrder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateCustomRulesOrder' +type ProfileRepository_UpdateCustomRulesOrder_Call struct { + *mock.Call +} + +// UpdateCustomRulesOrder is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - idToOrder map[string]int +func (_e *ProfileRepository_Expecter) UpdateCustomRulesOrder(ctx interface{}, profileId interface{}, idToOrder interface{}) *ProfileRepository_UpdateCustomRulesOrder_Call { + return &ProfileRepository_UpdateCustomRulesOrder_Call{Call: _e.mock.On("UpdateCustomRulesOrder", ctx, profileId, idToOrder)} +} + +func (_c *ProfileRepository_UpdateCustomRulesOrder_Call) Run(run func(ctx context.Context, profileId string, idToOrder map[string]int)) *ProfileRepository_UpdateCustomRulesOrder_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 map[string]int + if args[2] != nil { + arg2 = args[2].(map[string]int) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ProfileRepository_UpdateCustomRulesOrder_Call) Return(err error) *ProfileRepository_UpdateCustomRulesOrder_Call { + _c.Call.Return(err) + return _c +} + +func (_c *ProfileRepository_UpdateCustomRulesOrder_Call) RunAndReturn(run func(ctx context.Context, profileId string, idToOrder map[string]int) error) *ProfileRepository_UpdateCustomRulesOrder_Call { + _c.Call.Return(run) + return _c +} + // UpdateSettings provides a mock function for the type ProfileRepository func (_mock *ProfileRepository) UpdateSettings(ctx context.Context, profileId string, settings *model.ProfileSettings) error { ret := _mock.Called(ctx, profileId, settings) diff --git a/api/mocks/profile_servicer.go b/api/mocks/profile_servicer.go index 4f874db7..be3851c1 100644 --- a/api/mocks/profile_servicer.go +++ b/api/mocks/profile_servicer.go @@ -39,6 +39,75 @@ func (_m *ProfileServicer) EXPECT() *ProfileServicer_Expecter { return &ProfileServicer_Expecter{mock: &_m.Mock} } +// ApplyCustomRuleGroupOps provides a mock function for the type ProfileServicer +func (_mock *ProfileServicer) ApplyCustomRuleGroupOps(ctx context.Context, accountId string, profileId string, ops []profile.CustomRuleGroupOp) error { + ret := _mock.Called(ctx, accountId, profileId, ops) + + if len(ret) == 0 { + panic("no return value specified for ApplyCustomRuleGroupOps") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []profile.CustomRuleGroupOp) error); ok { + r0 = returnFunc(ctx, accountId, profileId, ops) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// ProfileServicer_ApplyCustomRuleGroupOps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyCustomRuleGroupOps' +type ProfileServicer_ApplyCustomRuleGroupOps_Call struct { + *mock.Call +} + +// ApplyCustomRuleGroupOps is a helper method to define mock.On call +// - ctx context.Context +// - accountId string +// - profileId string +// - ops []profile.CustomRuleGroupOp +func (_e *ProfileServicer_Expecter) ApplyCustomRuleGroupOps(ctx interface{}, accountId interface{}, profileId interface{}, ops interface{}) *ProfileServicer_ApplyCustomRuleGroupOps_Call { + return &ProfileServicer_ApplyCustomRuleGroupOps_Call{Call: _e.mock.On("ApplyCustomRuleGroupOps", ctx, accountId, profileId, ops)} +} + +func (_c *ProfileServicer_ApplyCustomRuleGroupOps_Call) Run(run func(ctx context.Context, accountId string, profileId string, ops []profile.CustomRuleGroupOp)) *ProfileServicer_ApplyCustomRuleGroupOps_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 []profile.CustomRuleGroupOp + if args[3] != nil { + arg3 = args[3].([]profile.CustomRuleGroupOp) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *ProfileServicer_ApplyCustomRuleGroupOps_Call) Return(err error) *ProfileServicer_ApplyCustomRuleGroupOps_Call { + _c.Call.Return(err) + return _c +} + +func (_c *ProfileServicer_ApplyCustomRuleGroupOps_Call) RunAndReturn(run func(ctx context.Context, accountId string, profileId string, ops []profile.CustomRuleGroupOp) error) *ProfileServicer_ApplyCustomRuleGroupOps_Call { + _c.Call.Return(run) + return _c +} + // CreateCustomRule provides a mock function for the type ProfileServicer func (_mock *ProfileServicer) CreateCustomRule(ctx context.Context, accountId string, profileId string, action string, value string) error { ret := _mock.Called(ctx, accountId, profileId, action, value) @@ -1371,6 +1440,161 @@ func (_c *ProfileServicer_Import_Call) RunAndReturn(run func(ctx context.Context return _c } +// ReorderCustomRules provides a mock function for the type ProfileServicer +func (_mock *ProfileServicer) ReorderCustomRules(ctx context.Context, accountId string, profileId string, orderedIds []string) error { + ret := _mock.Called(ctx, accountId, profileId, orderedIds) + + if len(ret) == 0 { + panic("no return value specified for ReorderCustomRules") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) error); ok { + r0 = returnFunc(ctx, accountId, profileId, orderedIds) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// ProfileServicer_ReorderCustomRules_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReorderCustomRules' +type ProfileServicer_ReorderCustomRules_Call struct { + *mock.Call +} + +// ReorderCustomRules is a helper method to define mock.On call +// - ctx context.Context +// - accountId string +// - profileId string +// - orderedIds []string +func (_e *ProfileServicer_Expecter) ReorderCustomRules(ctx interface{}, accountId interface{}, profileId interface{}, orderedIds interface{}) *ProfileServicer_ReorderCustomRules_Call { + return &ProfileServicer_ReorderCustomRules_Call{Call: _e.mock.On("ReorderCustomRules", ctx, accountId, profileId, orderedIds)} +} + +func (_c *ProfileServicer_ReorderCustomRules_Call) Run(run func(ctx context.Context, accountId string, profileId string, orderedIds []string)) *ProfileServicer_ReorderCustomRules_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 []string + if args[3] != nil { + arg3 = args[3].([]string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *ProfileServicer_ReorderCustomRules_Call) Return(err error) *ProfileServicer_ReorderCustomRules_Call { + _c.Call.Return(err) + return _c +} + +func (_c *ProfileServicer_ReorderCustomRules_Call) RunAndReturn(run func(ctx context.Context, accountId string, profileId string, orderedIds []string) error) *ProfileServicer_ReorderCustomRules_Call { + _c.Call.Return(run) + return _c +} + +// UpdateCustomRule provides a mock function for the type ProfileServicer +func (_mock *ProfileServicer) UpdateCustomRule(ctx context.Context, accountId string, profileId string, customRuleId string, patch profile.CustomRulePatch) (*model.CustomRule, error) { + ret := _mock.Called(ctx, accountId, profileId, customRuleId, patch) + + if len(ret) == 0 { + panic("no return value specified for UpdateCustomRule") + } + + var r0 *model.CustomRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, profile.CustomRulePatch) (*model.CustomRule, error)); ok { + return returnFunc(ctx, accountId, profileId, customRuleId, patch) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, profile.CustomRulePatch) *model.CustomRule); ok { + r0 = returnFunc(ctx, accountId, profileId, customRuleId, patch) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.CustomRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, profile.CustomRulePatch) error); ok { + r1 = returnFunc(ctx, accountId, profileId, customRuleId, patch) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ProfileServicer_UpdateCustomRule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateCustomRule' +type ProfileServicer_UpdateCustomRule_Call struct { + *mock.Call +} + +// UpdateCustomRule is a helper method to define mock.On call +// - ctx context.Context +// - accountId string +// - profileId string +// - customRuleId string +// - patch profile.CustomRulePatch +func (_e *ProfileServicer_Expecter) UpdateCustomRule(ctx interface{}, accountId interface{}, profileId interface{}, customRuleId interface{}, patch interface{}) *ProfileServicer_UpdateCustomRule_Call { + return &ProfileServicer_UpdateCustomRule_Call{Call: _e.mock.On("UpdateCustomRule", ctx, accountId, profileId, customRuleId, patch)} +} + +func (_c *ProfileServicer_UpdateCustomRule_Call) Run(run func(ctx context.Context, accountId string, profileId string, customRuleId string, patch profile.CustomRulePatch)) *ProfileServicer_UpdateCustomRule_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 profile.CustomRulePatch + if args[4] != nil { + arg4 = args[4].(profile.CustomRulePatch) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *ProfileServicer_UpdateCustomRule_Call) Return(customRule *model.CustomRule, err error) *ProfileServicer_UpdateCustomRule_Call { + _c.Call.Return(customRule, err) + return _c +} + +func (_c *ProfileServicer_UpdateCustomRule_Call) RunAndReturn(run func(ctx context.Context, accountId string, profileId string, customRuleId string, patch profile.CustomRulePatch) (*model.CustomRule, error)) *ProfileServicer_UpdateCustomRule_Call { + _c.Call.Return(run) + return _c +} + // UpdateProfile provides a mock function for the type ProfileServicer func (_mock *ProfileServicer) UpdateProfile(ctx context.Context, accountId string, profileId string, updates []model.ProfileUpdate) (*model.Profile, error) { ret := _mock.Called(ctx, accountId, profileId, updates) diff --git a/api/mocks/servicer.go b/api/mocks/servicer.go index 726fb612..ed16c572 100644 --- a/api/mocks/servicer.go +++ b/api/mocks/servicer.go @@ -102,6 +102,75 @@ func (_c *Servicer_AddPASession_Call) RunAndReturn(run func(ctx context.Context, return _c } +// ApplyCustomRuleGroupOps provides a mock function for the type Servicer +func (_mock *Servicer) ApplyCustomRuleGroupOps(ctx context.Context, accountId string, profileId string, ops []profile.CustomRuleGroupOp) error { + ret := _mock.Called(ctx, accountId, profileId, ops) + + if len(ret) == 0 { + panic("no return value specified for ApplyCustomRuleGroupOps") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []profile.CustomRuleGroupOp) error); ok { + r0 = returnFunc(ctx, accountId, profileId, ops) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Servicer_ApplyCustomRuleGroupOps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyCustomRuleGroupOps' +type Servicer_ApplyCustomRuleGroupOps_Call struct { + *mock.Call +} + +// ApplyCustomRuleGroupOps is a helper method to define mock.On call +// - ctx context.Context +// - accountId string +// - profileId string +// - ops []profile.CustomRuleGroupOp +func (_e *Servicer_Expecter) ApplyCustomRuleGroupOps(ctx interface{}, accountId interface{}, profileId interface{}, ops interface{}) *Servicer_ApplyCustomRuleGroupOps_Call { + return &Servicer_ApplyCustomRuleGroupOps_Call{Call: _e.mock.On("ApplyCustomRuleGroupOps", ctx, accountId, profileId, ops)} +} + +func (_c *Servicer_ApplyCustomRuleGroupOps_Call) Run(run func(ctx context.Context, accountId string, profileId string, ops []profile.CustomRuleGroupOp)) *Servicer_ApplyCustomRuleGroupOps_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 []profile.CustomRuleGroupOp + if args[3] != nil { + arg3 = args[3].([]profile.CustomRuleGroupOp) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *Servicer_ApplyCustomRuleGroupOps_Call) Return(err error) *Servicer_ApplyCustomRuleGroupOps_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Servicer_ApplyCustomRuleGroupOps_Call) RunAndReturn(run func(ctx context.Context, accountId string, profileId string, ops []profile.CustomRuleGroupOp) error) *Servicer_ApplyCustomRuleGroupOps_Call { + _c.Call.Return(run) + return _c +} + // BeginLogin provides a mock function for the type Servicer func (_mock *Servicer) BeginLogin(ctx context.Context, email string) (*protocol.CredentialAssertion, string, error) { ret := _mock.Called(ctx, email) @@ -3314,6 +3383,75 @@ func (_c *Servicer_PurgeAccountData_Call) RunAndReturn(run func(ctx context.Cont return _c } +// ReorderCustomRules provides a mock function for the type Servicer +func (_mock *Servicer) ReorderCustomRules(ctx context.Context, accountId string, profileId string, orderedIds []string) error { + ret := _mock.Called(ctx, accountId, profileId, orderedIds) + + if len(ret) == 0 { + panic("no return value specified for ReorderCustomRules") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) error); ok { + r0 = returnFunc(ctx, accountId, profileId, orderedIds) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Servicer_ReorderCustomRules_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReorderCustomRules' +type Servicer_ReorderCustomRules_Call struct { + *mock.Call +} + +// ReorderCustomRules is a helper method to define mock.On call +// - ctx context.Context +// - accountId string +// - profileId string +// - orderedIds []string +func (_e *Servicer_Expecter) ReorderCustomRules(ctx interface{}, accountId interface{}, profileId interface{}, orderedIds interface{}) *Servicer_ReorderCustomRules_Call { + return &Servicer_ReorderCustomRules_Call{Call: _e.mock.On("ReorderCustomRules", ctx, accountId, profileId, orderedIds)} +} + +func (_c *Servicer_ReorderCustomRules_Call) Run(run func(ctx context.Context, accountId string, profileId string, orderedIds []string)) *Servicer_ReorderCustomRules_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 []string + if args[3] != nil { + arg3 = args[3].([]string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *Servicer_ReorderCustomRules_Call) Return(err error) *Servicer_ReorderCustomRules_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Servicer_ReorderCustomRules_Call) RunAndReturn(run func(ctx context.Context, accountId string, profileId string, orderedIds []string) error) *Servicer_ReorderCustomRules_Call { + _c.Call.Return(run) + return _c +} + // RequestEmailVerificationOTP provides a mock function for the type Servicer func (_mock *Servicer) RequestEmailVerificationOTP(ctx context.Context, accountId string) error { ret := _mock.Called(ctx, accountId) @@ -3986,6 +4124,92 @@ func (_c *Servicer_UpdateCredential_Call) RunAndReturn(run func(context1 context return _c } +// UpdateCustomRule provides a mock function for the type Servicer +func (_mock *Servicer) UpdateCustomRule(ctx context.Context, accountId string, profileId string, customRuleId string, patch profile.CustomRulePatch) (*model.CustomRule, error) { + ret := _mock.Called(ctx, accountId, profileId, customRuleId, patch) + + if len(ret) == 0 { + panic("no return value specified for UpdateCustomRule") + } + + var r0 *model.CustomRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, profile.CustomRulePatch) (*model.CustomRule, error)); ok { + return returnFunc(ctx, accountId, profileId, customRuleId, patch) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, profile.CustomRulePatch) *model.CustomRule); ok { + r0 = returnFunc(ctx, accountId, profileId, customRuleId, patch) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.CustomRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, profile.CustomRulePatch) error); ok { + r1 = returnFunc(ctx, accountId, profileId, customRuleId, patch) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Servicer_UpdateCustomRule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateCustomRule' +type Servicer_UpdateCustomRule_Call struct { + *mock.Call +} + +// UpdateCustomRule is a helper method to define mock.On call +// - ctx context.Context +// - accountId string +// - profileId string +// - customRuleId string +// - patch profile.CustomRulePatch +func (_e *Servicer_Expecter) UpdateCustomRule(ctx interface{}, accountId interface{}, profileId interface{}, customRuleId interface{}, patch interface{}) *Servicer_UpdateCustomRule_Call { + return &Servicer_UpdateCustomRule_Call{Call: _e.mock.On("UpdateCustomRule", ctx, accountId, profileId, customRuleId, patch)} +} + +func (_c *Servicer_UpdateCustomRule_Call) Run(run func(ctx context.Context, accountId string, profileId string, customRuleId string, patch profile.CustomRulePatch)) *Servicer_UpdateCustomRule_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 profile.CustomRulePatch + if args[4] != nil { + arg4 = args[4].(profile.CustomRulePatch) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *Servicer_UpdateCustomRule_Call) Return(customRule *model.CustomRule, err error) *Servicer_UpdateCustomRule_Call { + _c.Call.Return(customRule, err) + return _c +} + +func (_c *Servicer_UpdateCustomRule_Call) RunAndReturn(run func(ctx context.Context, accountId string, profileId string, customRuleId string, patch profile.CustomRulePatch) (*model.CustomRule, error)) *Servicer_UpdateCustomRule_Call { + _c.Call.Return(run) + return _c +} + // UpdateProfile provides a mock function for the type Servicer func (_mock *Servicer) UpdateProfile(ctx context.Context, accountId string, profileId string, updates []model.ProfileUpdate) (*model.Profile, error) { ret := _mock.Called(ctx, accountId, profileId, updates) diff --git a/api/model/export.go b/api/model/export.go index e0463803..b4f4832a 100644 --- a/api/model/export.go +++ b/api/model/export.go @@ -60,9 +60,13 @@ type ExportedSettings struct { Security *ExportedSecurity `json:"security,omitempty"` // CustomRules holds the profile's custom filtering rules, capped at 1000 per profile. CustomRules []ExportedCustomRule `json:"customRules,omitempty" validate:"max=1000,dive"` - Logs *ExportedLogs `json:"logs,omitempty"` - Statistics *ExportedStatistics `json:"statistics,omitempty"` - Advanced *ExportedAdvanced `json:"advanced,omitempty"` + // CustomRuleGroups is the per-list group registry; reuses the storage type + // (its json tags define the wire shape). Pointer so an empty registry is + // omitted. Round-trips with the rules' `group` field. + CustomRuleGroups *CustomRuleGroups `json:"customRuleGroups,omitempty"` + Logs *ExportedLogs `json:"logs,omitempty"` + Statistics *ExportedStatistics `json:"statistics,omitempty"` + Advanced *ExportedAdvanced `json:"advanced,omitempty"` } // ExportedPrivacy carries the privacy section of a profile. @@ -90,11 +94,17 @@ type ExportedDNSSEC struct { // ExportedCustomRule represents a single user-authored filtering rule. // Note: addedAt is not in v1 -- CustomRule model has no timestamp field. +// The rule's display `order` is intentionally NOT exported: it is positional and +// re-derived from the array index on import. // specRef: V10–V14, F4 type ExportedCustomRule struct { - Action string `json:"action" validate:"required,oneof=block allow comment"` - Value string `json:"value" validate:"required,max=255"` - Comment string `json:"comment,omitempty" validate:"omitempty,max=200,safe_name"` + Action string `json:"action" validate:"required,oneof=block allow comment"` + Value string `json:"value" validate:"required,max=255"` + // Note is a free-text annotation. Free text (not safe_name) so users can write + // arbitrary reminders; length-capped to match the model/PATCH validators. + Note string `json:"note,omitempty" validate:"omitempty,max=80"` + // Group is the optional organizational label this rule belongs to. + Group string `json:"group,omitempty" validate:"omitempty,max=64"` } // ExportedLogs carries the log settings for a profile. diff --git a/api/model/profile.go b/api/model/profile.go index 4a571339..2c13053d 100644 --- a/api/model/profile.go +++ b/api/model/profile.go @@ -28,6 +28,15 @@ const MaxCustomRulesPerProfile = 10000 // regression test in profile_test.go keeps them aligned — change both together. const ExportedCustomRulesLimit = 1000 +// ExportedCustomRuleGroupsLimit is the maximum number of custom-rule groups emitted +// per list (denylist/allowlist) in an export, and the per-list cap accepted on +// import. Groups are not bounded by rule count (empty groups exist in the registry +// independently of any rule), so they need their own modest guard against +// empty-group bloat — real users have a handful. Mirrors the `max` literal in the +// CustomRuleGroups.Block/Allow tags; the regression test in profile_test.go keeps +// them aligned — change both together. +const ExportedCustomRuleGroupsLimit = 100 + // Profile represents a DNS profile type Profile struct { ID primitive.ObjectID `json:"id" bson:"_id" binding:"required"` diff --git a/api/model/profile_settings.go b/api/model/profile_settings.go index b97c3504..fa32f2c6 100644 --- a/api/model/profile_settings.go +++ b/api/model/profile_settings.go @@ -35,13 +35,16 @@ var ( // ProfileSettings represents profile settings, it's internal model used in `profiles` collection type ProfileSettings struct { - ProfileId string `json:"profile_id" bson:"profile_id" redis:"profile_id" binding:"required"` - Security *Security `json:"security" bson:"security" redis:"security" binding:"required"` - Privacy *Privacy `json:"privacy" bson:"privacy" redis:"privacy" binding:"required"` - CustomRules []*CustomRule `json:"custom_rules" bson:"custom_rules" redis:"-"` - Logs *LogsSettings `json:"logs" bson:"logs" redis:"-" binding:"required"` - Statistics *StatisticsSettings `json:"statistics" bson:"statistics" redis:"-" binding:"required"` - Advanced *Advanced `json:"advanced" bson:"advanced" redis:"advanced" binding:"required"` + ProfileId string `json:"profile_id" bson:"profile_id" redis:"profile_id" binding:"required"` + Security *Security `json:"security" bson:"security" redis:"security" binding:"required"` + Privacy *Privacy `json:"privacy" bson:"privacy" redis:"privacy" binding:"required"` + CustomRules []*CustomRule `json:"custom_rules" bson:"custom_rules" redis:"-"` + // CustomRuleGroups is the per-list group registry (denylist/allowlist). + // Organizational metadata only; never synced to the proxy (redis:"-"). + CustomRuleGroups CustomRuleGroups `json:"custom_rule_groups" bson:"custom_rule_groups" redis:"-"` + Logs *LogsSettings `json:"logs" bson:"logs" redis:"-" binding:"required"` + Statistics *StatisticsSettings `json:"statistics" bson:"statistics" redis:"-" binding:"required"` + Advanced *Advanced `json:"advanced" bson:"advanced" redis:"advanced" binding:"required"` } // NewSettings creates a new, empty settings object @@ -69,7 +72,8 @@ func NewSettings() *ProfileSettings { Statistics: &StatisticsSettings{ Enabled: false, }, - CustomRules: make([]*CustomRule, 0), + CustomRules: make([]*CustomRule, 0), + CustomRuleGroups: CustomRuleGroups{}, Advanced: &Advanced{ Recursor: RECURSOR_DEFAULT, }, @@ -81,12 +85,130 @@ type StatisticsSettings struct { Enabled bool `json:"enabled" bson:"enabled" redis:"enabled" binding:"required"` } -// CustomRule represents a custom rule +// CustomRuleGroup is one organizational group within a list. Modeled as a struct +// (rather than a name→note map entry) so future per-group attributes — colour, +// icon, display order, etc. — can be added without a shape change. +type CustomRuleGroup struct { + Name string `json:"name" bson:"name" validate:"required,max=64"` + Comment string `json:"comment,omitempty" bson:"comment,omitempty" validate:"omitempty,max=80"` +} + +// CustomRuleGroups is the per-list group registry: groups are scoped to the +// denylist (Block) or allowlist (Allow), so the same name in each list is +// independent. Organizational metadata only; never synced to the proxy. +type CustomRuleGroups struct { + Block []CustomRuleGroup `json:"block,omitempty" bson:"block,omitempty" validate:"omitempty,max=100,dive"` + Allow []CustomRuleGroup `json:"allow,omitempty" bson:"allow,omitempty" validate:"omitempty,max=100,dive"` +} + +// list returns the group slice for an action ("" if the action is unknown). +func (g *CustomRuleGroups) list(action string) []CustomRuleGroup { + switch action { + case ACTION_BLOCK: + return g.Block + case ACTION_ALLOW: + return g.Allow + } + return nil +} + +// assign writes the group slice back for an action, normalizing empty to nil so +// the stored/serialized shape stays clean (omitempty drops it). +func (g *CustomRuleGroups) assign(action string, list []CustomRuleGroup) { + if len(list) == 0 { + list = nil + } + switch action { + case ACTION_BLOCK: + g.Block = list + case ACTION_ALLOW: + g.Allow = list + } +} + +// Upsert sets a group's comment in the given list, creating the group if absent. +func (g *CustomRuleGroups) Upsert(action, name, comment string) { + list := g.list(action) + for i := range list { + if list[i].Name == name { + list[i].Comment = comment + g.assign(action, list) + return + } + } + g.assign(action, append(list, CustomRuleGroup{Name: name, Comment: comment})) +} + +// Remove drops a group from the given list (its rules are reassigned separately). +func (g *CustomRuleGroups) Remove(action, name string) { + list := g.list(action) + out := make([]CustomRuleGroup, 0, len(list)) + for _, grp := range list { + if grp.Name != name { + out = append(out, grp) + } + } + g.assign(action, out) +} + +// Rename renames `from`→`to` in the given list. If `to` already exists the move +// merges into it (its comment is kept); otherwise `from`'s comment is carried over. +func (g *CustomRuleGroups) Rename(action, from, to string) { + if from == to { + return + } + list := g.list(action) + var fromComment string + hadFrom, hasTo := false, false + out := make([]CustomRuleGroup, 0, len(list)) + for _, grp := range list { + switch grp.Name { + case from: + fromComment, hadFrom = grp.Comment, true + case to: + hasTo = true + out = append(out, grp) + default: + out = append(out, grp) + } + } + if !hasTo { + comment := "" + if hadFrom { + comment = fromComment + } + out = append(out, CustomRuleGroup{Name: to, Comment: comment}) + } + g.assign(action, out) +} + +// Clone returns a deep copy so service ops can mutate without touching the loaded profile. +func (g CustomRuleGroups) Clone() CustomRuleGroups { + cp := CustomRuleGroups{} + if g.Block != nil { + cp.Block = append([]CustomRuleGroup(nil), g.Block...) + } + if g.Allow != nil { + cp.Allow = append([]CustomRuleGroup(nil), g.Allow...) + } + return cp +} + +// CustomRule represents a custom rule. +// +// Note, Group and Order are organizational metadata used only by the API and +// frontend. They carry `redis:"-"` so they never reach the proxy hash, which +// reads exactly {action, value, syntax}. Order is a dense per-profile display +// index (0..N-1); it does NOT affect filtering precedence (precedence stays +// action-based in the proxy). type CustomRule struct { ID primitive.ObjectID `json:"id" bson:"_id" redis:"-" binding:"required"` Action CustomRuleAction `json:"action" bson:"action" redis:"action" binding:"required"` Value string `json:"value" bson:"value" redis:"value" binding:"required"` Syntax CustomRuleSyntax `json:"-" bson:"syntax" redis:"syntax" binding:"required"` + Note string `json:"note" bson:"note" redis:"-"` + Group string `json:"group" bson:"group" redis:"-"` + Order int `json:"order" bson:"order" redis:"-"` } // CustomRuleAction represents a custom rule action type diff --git a/api/model/profile_test.go b/api/model/profile_test.go index 9293cfdc..7018bc87 100644 --- a/api/model/profile_test.go +++ b/api/model/profile_test.go @@ -75,3 +75,17 @@ func TestExportedCustomRules_MaxMatchesCanonicalConst(t *testing.T) { "ExportedSettings.CustomRules validate tag has max=%d but ExportedCustomRulesLimit=%d — update both together", got, ExportedCustomRulesLimit) } + +// TestExportedCustomRuleGroups_MaxMatchesCanonicalConst is the drift guard for the +// per-list custom-rule-group limit: both Block and Allow tags must equal +// ExportedCustomRuleGroupsLimit (the value export truncates to and import accepts). +func TestExportedCustomRuleGroups_MaxMatchesCanonicalConst(t *testing.T) { + for _, field := range []string{"Block", "Allow"} { + f, ok := reflect.TypeOf(CustomRuleGroups{}).FieldByName(field) + require.True(t, ok, "CustomRuleGroups.%s field not found", field) + got := extractMaxFromValidateTag(t, "model.CustomRuleGroups."+field, f.Tag) + assert.Equal(t, ExportedCustomRuleGroupsLimit, got, + "CustomRuleGroups.%s validate tag has max=%d but ExportedCustomRuleGroupsLimit=%d — update both together", + field, got, ExportedCustomRuleGroupsLimit) + } +} diff --git a/api/service/profile/custom_rules.go b/api/service/profile/custom_rules.go index 3adbc9a0..f6541eb3 100644 --- a/api/service/profile/custom_rules.go +++ b/api/service/profile/custom_rules.go @@ -89,9 +89,13 @@ func (p *ProfileService) CreateCustomRulesBulk(ctx context.Context, accountId, p payloadSeen := make(map[string]struct{}, len(values)) toCreate := make([]*model.CustomRule, 0) + // New rules append to the end of the existing display order. The base is the + // current rule count; each created rule takes the next dense index. + baseOrder := len(profile.Settings.CustomRules) + for _, original := range values { - trimmed := strings.TrimSpace(original) - if trimmed == "" { + normalized := normalizeRuleValue(profile.Settings, original) + if normalized == "" { result.Skipped = append(result.Skipped, BulkCustomRuleSkipped{ Value: original, Reason: BulkCustomRuleSkipReasonInvalidSyntax, @@ -100,34 +104,6 @@ func (p *ProfileService) CreateCustomRulesBulk(ctx context.Context, accountId, p continue } - normalized, _ := strings.CutSuffix(trimmed, ".") - // Support ".example.com" syntax by normalizing to "*.example.com" for validation/storage - if strings.HasPrefix(normalized, ".") { - normalized = "*" + normalized - } - - // Normalize ASN rules: allow both "AS15169" and "15169" inputs, store canonical digits only. - if asnNormalized, ok := normalizeASN(normalized); ok { - normalized = asnNormalized - } - - // When custom_rules_subdomains_rule is "include" (or empty/unset for backwards compat), - // auto-prepend "*." to plain FQDN values so subdomains are included. - // Skip values that already express subdomain/non-FQDN semantics: - // - wildcards (already contain "*") - // - dot-prefix (".facebook.com" was already normalized to "*.facebook.com" above) - // - IPs (v4/v6) - // - CIDRs ("1.2.3.0/24", "2001:db8::/32" — contain "/") - // - ASNs ("15169") - if profile.Settings.Privacy.CustomRulesSubdomainsRule != model.CUSTOM_RULES_SUBDOMAINS_EXACT { - if !strings.Contains(normalized, "*") && !strings.Contains(normalized, "/") && - net.ParseIP(normalized) == nil { - if _, isASN := normalizeASN(normalized); !isASN { - normalized = "*." + normalized - } - } - } - if _, exists := payloadSeen[normalized]; exists { result.Skipped = append(result.Skipped, BulkCustomRuleSkipped{ Value: normalized, @@ -163,6 +139,7 @@ func (p *ProfileService) CreateCustomRulesBulk(ctx context.Context, accountId, p Action: actionCustomRule, Value: normalized, Syntax: syntax, + Order: baseOrder + len(toCreate), } toCreate = append(toCreate, customRule) @@ -188,6 +165,51 @@ func (p *ProfileService) CreateCustomRulesBulk(ctx context.Context, accountId, p return result, nil } +// normalizeRuleValue applies the canonical custom-rule normalization pipeline to a +// raw value: trim, strip trailing dot, ".x" -> "*.x", ASN canonicalization, and +// (unless the profile uses the "exact" subdomain rule) auto-prepend "*." to plain +// FQDNs so subdomains are included. It does NOT validate syntax — callers derive +// syntax via model.NewCustomRuleSyntax. Returns "" for empty input. +// +// Shared by CreateCustomRulesBulk and UpdateCustomRule so the create and edit +// paths never drift. +func normalizeRuleValue(settings *model.ProfileSettings, original string) string { + trimmed := strings.TrimSpace(original) + if trimmed == "" { + return "" + } + + normalized, _ := strings.CutSuffix(trimmed, ".") + // Support ".example.com" syntax by normalizing to "*.example.com" for validation/storage + if strings.HasPrefix(normalized, ".") { + normalized = "*" + normalized + } + + // Normalize ASN rules: allow both "AS15169" and "15169" inputs, store canonical digits only. + if asnNormalized, ok := normalizeASN(normalized); ok { + normalized = asnNormalized + } + + // When custom_rules_subdomains_rule is "include" (or empty/unset for backwards compat), + // auto-prepend "*." to plain FQDN values so subdomains are included. + // Skip values that already express subdomain/non-FQDN semantics: + // - wildcards (already contain "*") + // - dot-prefix (".facebook.com" was already normalized to "*.facebook.com" above) + // - IPs (v4/v6) + // - CIDRs ("1.2.3.0/24", "2001:db8::/32" — contain "/") + // - ASNs ("15169") + if settings.Privacy.CustomRulesSubdomainsRule != model.CUSTOM_RULES_SUBDOMAINS_EXACT { + if !strings.Contains(normalized, "*") && !strings.Contains(normalized, "/") && + net.ParseIP(normalized) == nil { + if _, isASN := normalizeASN(normalized); !isASN { + normalized = "*." + normalized + } + } + } + + return normalized +} + func normalizeASN(value string) (string, bool) { trimmed := strings.TrimSpace(value) if trimmed == "" { @@ -234,5 +256,211 @@ func (p *ProfileService) DeleteCustomRule(ctx context.Context, accountId, profil if err = p.Cache.RemoveCustomRule(ctx, profileId, customRuleId); err != nil { return err } + return nil } + +// CustomRulePatch carries the partial-update fields for UpdateCustomRule. A nil +// pointer means "leave unchanged"; a non-nil pointer (including an empty string) +// is applied. This lets callers explicitly clear note/group. +type CustomRulePatch struct { + Action *string + Value *string + Note *string + Group *string + Order *int +} + +// UpdateCustomRule applies a partial update to a single rule in place, preserving +// its stable ObjectID. When the value changes it is re-normalized, its syntax is +// re-derived, and it is dup-checked against the profile's OTHER rules. Redis is +// re-synced only when a proxy-relevant field (action/value/syntax) changes; pure +// note/group/order edits do no cache work. Returns the updated rule. +func (p *ProfileService) UpdateCustomRule(ctx context.Context, accountId, profileId, customRuleId string, patch CustomRulePatch) (*model.CustomRule, error) { + profile, err := p.validateProfileIdAffiliation(ctx, accountId, profileId) + if err != nil { + return nil, err + } + + var existing *model.CustomRule + for _, r := range profile.Settings.CustomRules { + if r.ID.Hex() == customRuleId { + existing = r + break + } + } + if existing == nil { + return nil, dbErrors.ErrCustomRuleNotFound + } + + updated := *existing + proxyFieldsChanged := false + + if patch.Action != nil { + action, err := model.NewCustomRuleAction(*patch.Action) + if err != nil { + return nil, err + } + if action != updated.Action { + updated.Action = action + proxyFieldsChanged = true + } + } + + if patch.Value != nil { + normalized := normalizeRuleValue(profile.Settings, *patch.Value) + if normalized == "" { + return nil, model.ErrInvalidCustomRuleSyntax + } + syntax, err := model.NewCustomRuleSyntax(p.Validate, normalized) + if err != nil { + return nil, model.ErrInvalidCustomRuleSyntax + } + if normalized != updated.Value { + // Duplicate check excludes the rule being edited. + for _, r := range profile.Settings.CustomRules { + if r.ID.Hex() != customRuleId && r.Value == normalized { + return nil, ErrCustomRuleAlreadyExists + } + } + updated.Value = normalized + updated.Syntax = syntax + proxyFieldsChanged = true + } + } + + if patch.Note != nil { + updated.Note = *patch.Note + } + if patch.Group != nil { + updated.Group = *patch.Group + } + if patch.Order != nil { + updated.Order = *patch.Order + } + + if err := p.ProfileRepository.UpdateCustomRule(ctx, profileId, &updated); err != nil { + return nil, err + } + + // Re-sync the proxy only when a field the proxy reads actually changed. + // AddCustomRules HSets the rule hash (overwriting fields) and is idempotent + // on the set membership, so a single-element slice re-syncs the edited rule. + if proxyFieldsChanged { + if err := p.Cache.AddCustomRules(ctx, profileId, []*model.CustomRule{&updated}); err != nil { + return nil, err + } + } + + return &updated, nil +} + +// ReorderCustomRules sets the display order of the profile's rules to match the +// position of each ID in orderedIds (index 0 first). Every ID must belong to the +// profile. Order is organizational only and is never synced to the proxy. +// +// Callers should send the complete ordered ID list for the profile; IDs omitted +// from orderedIds keep their stored order, which can collide with the renumbered +// ones, so partial lists are discouraged. +func (p *ProfileService) ReorderCustomRules(ctx context.Context, accountId, profileId string, orderedIds []string) error { + profile, err := p.validateProfileIdAffiliation(ctx, accountId, profileId) + if err != nil { + return err + } + + known := make(map[string]struct{}, len(profile.Settings.CustomRules)) + for _, r := range profile.Settings.CustomRules { + known[r.ID.Hex()] = struct{}{} + } + + idToOrder := make(map[string]int, len(orderedIds)) + for i, id := range orderedIds { + if _, ok := known[id]; !ok { + return dbErrors.ErrCustomRuleNotFound + } + if _, dup := idToOrder[id]; dup { + continue + } + idToOrder[id] = i + } + + return p.ProfileRepository.UpdateCustomRulesOrder(ctx, profileId, idToOrder) +} + +// SetCustomRuleGroups upserts group-note entries. A nil note value deletes that +// group's note. The map is merged onto the profile's current group-note map and +// persisted wholesale. Group notes are metadata only and never reach the proxy. +// maxGroupNameLen bounds a decoded group name. Mirrors the frontend/edit limit. +const maxGroupNameLen = 64 + +// CustomRuleGroupOp is one JSON-Patch-style operation on the per-list group +// registry. The handler decodes the request's JSON-Pointer path/from into the +// plain Group/From names before calling the service. Action ("block"/"allow") +// scopes the op to one list, so the denylist and allowlist have independent groups. +// - "add"/"replace": set Group's note to Note (creates the group). +// - "remove": delete Group (member rules → Ungrouped, note dropped). +// - "move": rename From → Group (reassign member rules, move the note). +type CustomRuleGroupOp struct { + Operation string + Action string + Group string + From string + Note *string +} + +// ApplyCustomRuleGroupOps applies a batch of per-list group-registry operations in +// order. Member-rule reassignments (remove/move) run as atomic, action-scoped bulk +// updates; the nested note map is folded in memory and persisted once at the end. +// Group labels are metadata only — no Redis writes. +func (p *ProfileService) ApplyCustomRuleGroupOps(ctx context.Context, accountId, profileId string, ops []CustomRuleGroupOp) error { + profile, err := p.validateProfileIdAffiliation(ctx, accountId, profileId) + if err != nil { + return err + } + + groups := profile.Settings.CustomRuleGroups.Clone() + + for _, op := range ops { + if op.Action != model.ACTION_BLOCK && op.Action != model.ACTION_ALLOW { + return model.ErrInvalidCustomRuleAction + } + + switch op.Operation { + case "add", "replace": + if op.Group == "" || len(op.Group) > maxGroupNameLen { + return model.ErrInvalidCustomRuleSyntax + } + comment := "" + if op.Note != nil { + comment = *op.Note + } + groups.Upsert(op.Action, op.Group, comment) + + case "remove": + if op.Group == "" { + return model.ErrInvalidCustomRuleSyntax + } + if err := p.ProfileRepository.ReassignCustomRuleGroup(ctx, profileId, op.Action, op.Group, ""); err != nil { + return err + } + groups.Remove(op.Action, op.Group) + + case "move": + if op.From == "" || op.Group == "" || len(op.Group) > maxGroupNameLen { + return model.ErrInvalidCustomRuleSyntax + } + if op.From == op.Group { + continue + } + if err := p.ProfileRepository.ReassignCustomRuleGroup(ctx, profileId, op.Action, op.From, op.Group); err != nil { + return err + } + groups.Rename(op.Action, op.From, op.Group) + + default: + return model.ErrInvalidCustomRuleSyntax + } + } + + return p.ProfileRepository.SetCustomRuleGroups(ctx, profileId, groups) +} diff --git a/api/service/profile/export.go b/api/service/profile/export.go index 9f7d63c4..66b14724 100644 --- a/api/service/profile/export.go +++ b/api/service/profile/export.go @@ -3,6 +3,7 @@ package profile import ( "context" "errors" + "sort" "time" dbErrors "github.com/ivpn/dns/api/db/errors" @@ -161,22 +162,36 @@ func exportSettings(s *model.ProfileSettings) *model.ExportedSettings { } } - // Custom rules — specRef: F7; internal ID field stripped per F9 + // Custom rules — specRef: F7; internal ID and positional order stripped per F9. + // Rules are emitted in display order so the import side can re-derive `order` + // from the array index. if len(s.CustomRules) > 0 { - rules := make([]model.ExportedCustomRule, 0, len(s.CustomRules)) + ordered := make([]*model.CustomRule, 0, len(s.CustomRules)) for _, r := range s.CustomRules { - if r == nil { - continue + if r != nil { + ordered = append(ordered, r) } + } + sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Order < ordered[j].Order }) + + rules := make([]model.ExportedCustomRule, 0, len(ordered)) + for _, r := range ordered { rules = append(rules, model.ExportedCustomRule{ Action: string(r.Action), Value: r.Value, - // Comment and AddedAt are not present in the model today — omit. + Note: r.Note, + Group: r.Group, }) } es.CustomRules = rules } + // Per-list groups round-trip alongside the rules' group labels. + if len(s.CustomRuleGroups.Block) > 0 || len(s.CustomRuleGroups.Allow) > 0 { + groups := s.CustomRuleGroups.Clone() + es.CustomRuleGroups = &groups + } + // Logs section — specRef: F3 (logs sub-fields) if s.Logs != nil { es.Logs = &model.ExportedLogs{ diff --git a/api/service/profile/export_service_test.go b/api/service/profile/export_service_test.go index ab23fdf5..c68a2334 100644 --- a/api/service/profile/export_service_test.go +++ b/api/service/profile/export_service_test.go @@ -91,9 +91,10 @@ func fullProfile(accountId string) *model.Profile { DNSSECSettings: model.DNSSECSettings{Enabled: true, SendDoBit: true}, } p.Settings.CustomRules = []*model.CustomRule{ - {ID: primitive.NewObjectID(), Action: "block", Value: "ads.example.com"}, - {ID: primitive.NewObjectID(), Action: "allow", Value: "safe.example.com"}, + {ID: primitive.NewObjectID(), Action: "block", Value: "ads.example.com", Note: "blocks ad network", Group: "Ads", Order: 0}, + {ID: primitive.NewObjectID(), Action: "allow", Value: "safe.example.com", Order: 1}, } + p.Settings.CustomRuleGroups = model.CustomRuleGroups{Block: []model.CustomRuleGroup{{Name: "Ads", Comment: "advertising domains"}}} p.Settings.Logs = &model.LogsSettings{ Enabled: true, LogClientsIPs: true, diff --git a/api/service/profile/import.go b/api/service/profile/import.go index cbf28a4f..59c3189b 100644 --- a/api/service/profile/import.go +++ b/api/service/profile/import.go @@ -42,6 +42,13 @@ var ErrImportNotImplemented = errors.New("import not implemented") // specRef: S6, V10 const maxCustomRulesPerProfile = model.ExportedCustomRulesLimit +// maxCustomRuleGroupsPerList is the per-list defensive cap on imported groups. +// The DTO layer enforces the same limit (CustomRuleGroups.Block/Allow max), but +// the service caps too so it stays safe when called without HTTP. Groups aren't +// bounded by rule count (empty groups exist independently), so they need their own +// guard. +const maxCustomRuleGroupsPerList = model.ExportedCustomRuleGroupsLimit + // staleExportThreshold is the age beyond which an export file triggers an advisory warning. // specRef: V16, V17 const staleExportThreshold = 90 * 24 * time.Hour @@ -264,6 +271,11 @@ func (p *ProfileService) importOneProfile( validRules, warnings = p.validateAndMapRules(rulesInput, resolvedName, accountId, warnings) } + // Defensive per-list cap on groups; the DTO layer enforces the same limit, but + // guard here too since groups aren't bounded by rule count. + settings.CustomRuleGroups.Block, warnings = capGroups(settings.CustomRuleGroups.Block, "denylist", resolvedName, warnings) + settings.CustomRuleGroups.Allow, warnings = capGroups(settings.CustomRuleGroups.Allow, "allowlist", resolvedName, warnings) + // Persist the profile document (without custom rules — those go via CreateCustomRules). newProfile := &model.Profile{ ID: primitive.NewObjectID(), @@ -342,6 +354,12 @@ func (p *ProfileService) mapExportedSettings(src *model.ExportedSettings, profil s.Statistics.Enabled = src.Statistics.Enabled } + // Per-list custom rule groups round-trip as-is. Groups that end up with no + // member rule (e.g. their rules were skipped) are harmless. + if src.CustomRuleGroups != nil { + s.CustomRuleGroups = src.CustomRuleGroups.Clone() + } + // Advanced section — specRef: F7 // Silently ignored on import. The recursor is a staging-only control; // imported profiles always inherit RECURSOR_DEFAULT from model.NewSettings(), @@ -474,12 +492,33 @@ func (p *ProfileService) validateAndMapRules( Action: action, Value: r.Value, Syntax: syntax, + Note: r.Note, + Group: r.Group, + // Order is re-derived from payload position; export omits it. Using the + // source index (not len(valid)) keeps spacing stable even when an + // earlier rule is skipped, and the values are still strictly increasing. + Order: i, }) } return valid, warnings } +// capGroups truncates a per-list group slice to maxCustomRuleGroupsPerList, +// appending a warning when entries are discarded. Defensive mirror of the rules +// cap; the DTO validator rejects over-limit payloads on the HTTP path. +func capGroups(list []model.CustomRuleGroup, listName, profileName string, warnings []string) ([]model.CustomRuleGroup, []string) { + if len(list) > maxCustomRuleGroupsPerList { + discarded := len(list) - maxCustomRuleGroupsPerList + list = list[:maxCustomRuleGroupsPerList] + warnings = append(warnings, fmt.Sprintf( + "profile '%s': %s groups capped at %d; %d were discarded", + profileName, listName, maxCustomRuleGroupsPerList, discarded, + )) + } + return list, warnings +} + // rollbackImportedProfiles deletes every profile in profileIds. This is the // in-process rollback path (spec row I22) — the only rollback path; there is // no persisted-tag cleanup pass behind it. Errors are logged at Warn level diff --git a/api/service/profile/import_test.go b/api/service/profile/import_test.go index 21df1370..01eff9b6 100644 --- a/api/service/profile/import_test.go +++ b/api/service/profile/import_test.go @@ -6,6 +6,7 @@ package profile_test import ( "context" + "fmt" "os" "strings" "testing" @@ -194,6 +195,121 @@ func TestImport_ModeCreateNew_Accepted(t *testing.T) { assert.Equal(t, []string{}, result.Warnings) } +// specRef: V11, V12, F4 — per-rule note/group and the group-note map survive an +// import, and display order is re-derived from payload position. +func TestImport_CustomRuleMetadata_RoundTrips(t *testing.T) { + env := newImportTestEnv(t, "secret", 100) + + var capturedRules []*model.CustomRule + var capturedSettings *model.ProfileSettings + + env.profileRepo.On("GetProfilesByAccountId", mock.Anything, "acct1"). + Return([]model.Profile{}, nil).Once() + env.idGen.On("Generate").Return("fresh-id-1", nil).Once() + env.profileRepo.On("CreateProfile", mock.Anything, mock.MatchedBy(func(p *model.Profile) bool { + capturedSettings = p.Settings + return true + })).Return(nil).Once() + env.cache.On("CreateOrUpdateProfileSettings", mock.Anything, + mock.AnythingOfType("*model.ProfileSettings"), true).Return(nil).Once() + env.profileRepo.On("CreateCustomRules", mock.Anything, "fresh-id-1", + mock.MatchedBy(func(rules []*model.CustomRule) bool { + capturedRules = rules + return true + })).Return(nil).Once() + env.cache.On("AddCustomRules", mock.Anything, "fresh-id-1", + mock.AnythingOfType("[]*model.CustomRule")).Return(nil).Once() + + envelope := &model.ExportEnvelope{ + SchemaVersion: 1, + Kind: "moddns-export", + ExportedAt: time.Now(), + Profiles: []model.ExportedProfile{{ + Name: "Imported", + Settings: &model.ExportedSettings{ + CustomRules: []model.ExportedCustomRule{ + {Action: "block", Value: "ads.example.com", Note: "ad net", Group: "Ads"}, + {Action: "allow", Value: "safe.example.com"}, + }, + CustomRuleGroups: &model.CustomRuleGroups{Block: []model.CustomRuleGroup{{Name: "Ads", Comment: "advertising"}}}, + }, + }}, + } + + result, err := env.svc.Import( + context.Background(), "acct1", + profile.ImportModeCreateNew, + envelope, + ptr("secret"), nil, nil, + ) + require.NoError(t, err) + require.Len(t, result.CreatedProfileIds, 1) + + require.Len(t, capturedRules, 2) + assert.Equal(t, "ad net", capturedRules[0].Note) + assert.Equal(t, "Ads", capturedRules[0].Group) + assert.Equal(t, 0, capturedRules[0].Order) + assert.Equal(t, 1, capturedRules[1].Order) + assert.Empty(t, capturedRules[1].Note) + + require.NotNil(t, capturedSettings) + require.Len(t, capturedSettings.CustomRuleGroups.Block, 1) + assert.Equal(t, "Ads", capturedSettings.CustomRuleGroups.Block[0].Name) + assert.Equal(t, "advertising", capturedSettings.CustomRuleGroups.Block[0].Comment) +} + +// TestImport_GroupsCappedPerList verifies the defensive per-list group cap: +// importing more than the limit truncates with a warning (the DTO validator is +// bypassed when Import is called directly). +func TestImport_GroupsCappedPerList(t *testing.T) { + env := newImportTestEnv(t, "secret", 100) + + var capturedSettings *model.ProfileSettings + env.profileRepo.On("GetProfilesByAccountId", mock.Anything, "acct1"). + Return([]model.Profile{}, nil).Once() + env.idGen.On("Generate").Return("fresh-id-1", nil).Once() + env.profileRepo.On("CreateProfile", mock.Anything, mock.MatchedBy(func(p *model.Profile) bool { + capturedSettings = p.Settings + return true + })).Return(nil).Once() + env.cache.On("CreateOrUpdateProfileSettings", mock.Anything, + mock.AnythingOfType("*model.ProfileSettings"), true).Return(nil).Once() + + over := model.ExportedCustomRuleGroupsLimit + 50 + blockGroups := make([]model.CustomRuleGroup, over) + for i := range blockGroups { + blockGroups[i] = model.CustomRuleGroup{Name: fmt.Sprintf("g%d", i)} + } + + envelope := &model.ExportEnvelope{ + SchemaVersion: 1, + Kind: "moddns-export", + ExportedAt: time.Now(), + Profiles: []model.ExportedProfile{{ + Name: "Imported", + Settings: &model.ExportedSettings{CustomRuleGroups: &model.CustomRuleGroups{Block: blockGroups}}, + }}, + } + + result, err := env.svc.Import( + context.Background(), "acct1", + profile.ImportModeCreateNew, + envelope, + ptr("secret"), nil, nil, + ) + require.NoError(t, err) + require.NotNil(t, capturedSettings) + assert.Len(t, capturedSettings.CustomRuleGroups.Block, model.ExportedCustomRuleGroupsLimit) + + var capped bool + for _, w := range result.Warnings { + if strings.Contains(w, "denylist groups capped at") { + capped = true + } + } + assert.True(t, capped, "expected a denylist-groups-capped warning, got %v", result.Warnings) +} + // specRef: I11 func TestImport_ModeUnknown_Rejected(t *testing.T) { env := newImportTestEnv(t, "secret", 100) diff --git a/api/service/profile/service_test.go b/api/service/profile/service_test.go index 2258088c..a2abfc54 100644 --- a/api/service/profile/service_test.go +++ b/api/service/profile/service_test.go @@ -2334,6 +2334,112 @@ func (suite *ProfileTestSuite) TestCreateCustomRulesBulkEnforcesPerProfileCap() mockCache.AssertNotCalled(suite.T(), "AddCustomRules", mock.Anything, mock.Anything, mock.Anything) } +// TestApplyCustomRuleGroupOps_Move (rename) reassigns the action's member rules and +// moves the note within that list only. +func (suite *ProfileTestSuite) TestApplyCustomRuleGroupOps_Move() { + ctx := context.Background() + const accountID = "acct-grp" + const profileID = "prof-grp" + + profileRec := &model.Profile{ + ProfileId: profileID, + AccountId: accountID, + Settings: &model.ProfileSettings{ + CustomRuleGroups: model.CustomRuleGroups{Block: []model.CustomRuleGroup{{Name: "Ads", Comment: "ad note"}}}, + CustomRules: []*model.CustomRule{}, + }, + } + + suite.mockProfileRepo.On("GetProfileById", ctx, profileID).Return(profileRec, nil).Once() + suite.mockProfileRepo.On("ReassignCustomRuleGroup", ctx, profileID, "block", "Ads", "Marketing").Return(nil).Once() + suite.mockProfileRepo.On("SetCustomRuleGroups", ctx, profileID, model.CustomRuleGroups{Block: []model.CustomRuleGroup{{Name: "Marketing", Comment: "ad note"}}}).Return(nil).Once() + + err := suite.service.ApplyCustomRuleGroupOps(ctx, accountID, profileID, []profile.CustomRuleGroupOp{ + {Operation: "move", Action: "block", From: "Ads", Group: "Marketing"}, + }) + suite.NoError(err) +} + +// TestApplyCustomRuleGroupOps_RemoveIsPerList deletes the denylist "Ads" group and +// confirms the reassignment is action-scoped and the same-named allowlist group is +// untouched. +func (suite *ProfileTestSuite) TestApplyCustomRuleGroupOps_RemoveIsPerList() { + ctx := context.Background() + const accountID = "acct-grp" + const profileID = "prof-grp" + + profileRec := &model.Profile{ + ProfileId: profileID, + AccountId: accountID, + Settings: &model.ProfileSettings{ + CustomRuleGroups: model.CustomRuleGroups{ + Block: []model.CustomRuleGroup{{Name: "Ads", Comment: "x"}}, + Allow: []model.CustomRuleGroup{{Name: "Ads", Comment: "keep me"}}, + }, + CustomRules: []*model.CustomRule{}, + }, + } + + suite.mockProfileRepo.On("GetProfileById", ctx, profileID).Return(profileRec, nil).Once() + // Reassign is scoped to action "block" only. + suite.mockProfileRepo.On("ReassignCustomRuleGroup", ctx, profileID, "block", "Ads", "").Return(nil).Once() + // The allowlist "Ads" group survives; only the block entry is removed (→ nil). + suite.mockProfileRepo.On("SetCustomRuleGroups", ctx, profileID, model.CustomRuleGroups{ + Allow: []model.CustomRuleGroup{{Name: "Ads", Comment: "keep me"}}, + }).Return(nil).Once() + + err := suite.service.ApplyCustomRuleGroupOps(ctx, accountID, profileID, []profile.CustomRuleGroupOp{ + {Operation: "remove", Action: "block", Group: "Ads"}, + }) + suite.NoError(err) +} + +// TestApplyCustomRuleGroupOps_AddSetsNote creates/updates a group note in the given +// list without touching any rules. +func (suite *ProfileTestSuite) TestApplyCustomRuleGroupOps_AddSetsNote() { + ctx := context.Background() + const accountID = "acct-grp" + const profileID = "prof-grp" + note := "tracking domains" + + profileRec := &model.Profile{ + ProfileId: profileID, + AccountId: accountID, + Settings: &model.ProfileSettings{ + CustomRuleGroups: model.CustomRuleGroups{}, + CustomRules: []*model.CustomRule{}, + }, + } + + suite.mockProfileRepo.On("GetProfileById", ctx, profileID).Return(profileRec, nil).Once() + suite.mockProfileRepo.On("SetCustomRuleGroups", ctx, profileID, model.CustomRuleGroups{Allow: []model.CustomRuleGroup{{Name: "Ads", Comment: note}}}).Return(nil).Once() + + err := suite.service.ApplyCustomRuleGroupOps(ctx, accountID, profileID, []profile.CustomRuleGroupOp{ + {Operation: "add", Action: "allow", Group: "Ads", Note: ¬e}, + }) + suite.NoError(err) +} + +// TestApplyCustomRuleGroupOps_RejectsBadAction rejects an op whose action is not +// block/allow; the registry is not written. +func (suite *ProfileTestSuite) TestApplyCustomRuleGroupOps_RejectsBadAction() { + ctx := context.Background() + const accountID = "acct-grp" + const profileID = "prof-empty" + + profileRec := &model.Profile{ + ProfileId: profileID, + AccountId: accountID, + Settings: &model.ProfileSettings{CustomRuleGroups: model.CustomRuleGroups{}}, + } + suite.mockProfileRepo.On("GetProfileById", ctx, profileID).Return(profileRec, nil).Once() + + err := suite.service.ApplyCustomRuleGroupOps(ctx, accountID, profileID, []profile.CustomRuleGroupOp{ + {Operation: "add", Action: "comment", Group: "Ads"}, + }) + suite.ErrorIs(err, model.ErrInvalidCustomRuleAction) +} + func TestProfileTestSuite(t *testing.T) { suite.Run(t, new(ProfileTestSuite)) } diff --git a/api/service/profile/testdata/export/full-profile.golden.json b/api/service/profile/testdata/export/full-profile.golden.json index 40818219..96b80c2a 100644 --- a/api/service/profile/testdata/export/full-profile.golden.json +++ b/api/service/profile/testdata/export/full-profile.golden.json @@ -27,13 +27,23 @@ "customRules": [ { "action": "block", - "value": "ads.example.com" + "value": "ads.example.com", + "note": "blocks ad network", + "group": "Ads" }, { "action": "allow", "value": "safe.example.com" } ], + "customRuleGroups": { + "block": [ + { + "name": "Ads", + "comment": "advertising domains" + } + ] + }, "logs": { "enabled": true, "logClientsIPs": true, diff --git a/api/service/service.go b/api/service/service.go index 4524c5a2..a51ee2af 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -154,6 +154,9 @@ type ProfileServicer interface { DeleteCustomRule(ctx context.Context, accountId, profileId, customRuleId string) error CreateCustomRule(ctx context.Context, accountId, profileId, action, value string) error CreateCustomRulesBulk(ctx context.Context, accountId, profileId, action string, values []string) (*profile.BulkCustomRuleResult, error) + UpdateCustomRule(ctx context.Context, accountId, profileId, customRuleId string, patch profile.CustomRulePatch) (*model.CustomRule, error) + ReorderCustomRules(ctx context.Context, accountId, profileId string, orderedIds []string) error + ApplyCustomRuleGroupOps(ctx context.Context, accountId, profileId string, ops []profile.CustomRuleGroupOp) error // Blocklists EnableBlocklists(ctx context.Context, accountId, profileId string, blocklistIds []string) error diff --git a/app/package-lock.json b/app/package-lock.json index 9549255f..7b111e3d 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,6 +8,9 @@ "name": "moddns", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.15", @@ -1780,6 +1783,59 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", diff --git a/app/package.json b/app/package.json index 42fd44b8..d6faf927 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,9 @@ "snapshots:mobile:ci": "MOBILE_SNAPSHOTS=1 playwright test -c src/__tests__/playwright.config.ts --project=chromium-mobile --project=iphone15pro src/__tests__/e2e/manual/mobileSnapshots.spec.ts" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.15", diff --git a/app/src/__tests__/unit/RuleComposer.test.tsx b/app/src/__tests__/unit/RuleComposer.test.tsx index c27f4de9..8104d450 100644 --- a/app/src/__tests__/unit/RuleComposer.test.tsx +++ b/app/src/__tests__/unit/RuleComposer.test.tsx @@ -71,4 +71,36 @@ describe("RuleComposer", () => { expect(onSubmit).toHaveBeenCalledTimes(1); }); + + it("tokenizes typed input AND submits in a single Add-button click", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const onTokensChange = vi.fn(); + renderRuleComposer({ onSubmit, onTokensChange }); + + const input = getEditableInput(); + await user.type(input, "example.com"); + + // The button is enabled even though no chip has been committed yet. + const addButton = screen.getByRole("button", { name: /add to denylist/i }); + expect(addButton).toBeEnabled(); + + await user.click(addButton); + + // The typed value is committed as a token and submitted in one action, + // with the freshly-merged list passed explicitly (parent state is async). + expect(onTokensChange).toHaveBeenCalledWith([ + { label: "example.com", value: "example.com" }, + ]); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith([ + { label: "example.com", value: "example.com" }, + ]); + }); + + it("keeps the Add button disabled when input and tokens are both empty", () => { + renderRuleComposer({ tokens: [] }); + const addButton = screen.getByRole("button", { name: /add to denylist/i }); + expect(addButton).toBeDisabled(); + }); }); diff --git a/app/src/api/client/api.ts b/app/src/api/client/api.ts index b8ef02e3..52534a46 100644 --- a/app/src/api/client/api.ts +++ b/app/src/api/client/api.ts @@ -489,12 +489,30 @@ export interface ModelCustomRule { * @memberof ModelCustomRule */ 'action': string; + /** + * + * @type {string} + * @memberof ModelCustomRule + */ + 'group'?: string; /** * * @type {string} * @memberof ModelCustomRule */ 'id': string; + /** + * + * @type {string} + * @memberof ModelCustomRule + */ + 'note'?: string; + /** + * + * @type {number} + * @memberof ModelCustomRule + */ + 'order'?: number; /** * * @type {string} @@ -502,6 +520,44 @@ export interface ModelCustomRule { */ 'value': string; } +/** + * + * @export + * @interface ModelCustomRuleGroup + */ +export interface ModelCustomRuleGroup { + /** + * + * @type {string} + * @memberof ModelCustomRuleGroup + */ + 'comment'?: string; + /** + * + * @type {string} + * @memberof ModelCustomRuleGroup + */ + 'name': string; +} +/** + * + * @export + * @interface ModelCustomRuleGroups + */ +export interface ModelCustomRuleGroups { + /** + * + * @type {Array} + * @memberof ModelCustomRuleGroups + */ + 'allow'?: Array; + /** + * + * @type {Array} + * @memberof ModelCustomRuleGroups + */ + 'block'?: Array; +} /** * * @export @@ -623,11 +679,17 @@ export interface ModelExportedCustomRule { */ 'action': ModelExportedCustomRuleActionEnum; /** - * + * Group is the optional organizational label this rule belongs to. * @type {string} * @memberof ModelExportedCustomRule */ - 'comment'?: string; + 'group'?: string; + /** + * Note is a free-text annotation. Free text (not safe_name) so users can write arbitrary reminders; length-capped to match the model/PATCH validators. + * @type {string} + * @memberof ModelExportedCustomRule + */ + 'note'?: string; /** * * @type {string} @@ -831,6 +893,12 @@ export interface ModelExportedSettings { * @memberof ModelExportedSettings */ 'advanced'?: ModelExportedAdvanced; + /** + * CustomRuleGroups is the per-list group registry; reuses the storage type (its json tags define the wire shape). Pointer so an empty registry is omitted. Round-trips with the rules\' `group` field. + * @type {ModelCustomRuleGroups} + * @memberof ModelExportedSettings + */ + 'customRuleGroups'?: ModelCustomRuleGroups; /** * CustomRules holds the profile\'s custom filtering rules, capped at 1000 per profile. * @type {Array} @@ -1027,6 +1095,12 @@ export interface ModelProfileSettings { * @memberof ModelProfileSettings */ 'advanced': ModelAdvanced; + /** + * CustomRuleGroups is the per-list group registry (denylist/allowlist). Organizational metadata only; never synced to the proxy (redis:\"-\"). + * @type {ModelCustomRuleGroups} + * @memberof ModelProfileSettings + */ + 'custom_rule_groups'?: ModelCustomRuleGroups; /** * * @type {Array} @@ -1966,6 +2040,72 @@ export const RequestsCreateProfileCustomRulesBatchBodyActionEnum = { export type RequestsCreateProfileCustomRulesBatchBodyActionEnum = typeof RequestsCreateProfileCustomRulesBatchBodyActionEnum[keyof typeof RequestsCreateProfileCustomRulesBatchBodyActionEnum]; +/** + * + * @export + * @interface RequestsCustomRuleGroupUpdate + */ +export interface RequestsCustomRuleGroupUpdate { + /** + * Action scopes the op to one list (\"block\" = denylist, \"allow\" = allowlist); groups are per-list. + * @type {string} + * @memberof RequestsCustomRuleGroupUpdate + */ + 'action': RequestsCustomRuleGroupUpdateActionEnum; + /** + * + * @type {string} + * @memberof RequestsCustomRuleGroupUpdate + */ + 'from'?: string; + /** + * + * @type {string} + * @memberof RequestsCustomRuleGroupUpdate + */ + 'operation': RequestsCustomRuleGroupUpdateOperationEnum; + /** + * + * @type {string} + * @memberof RequestsCustomRuleGroupUpdate + */ + 'path': string; + /** + * + * @type {string} + * @memberof RequestsCustomRuleGroupUpdate + */ + 'value'?: string; +} + +export const RequestsCustomRuleGroupUpdateActionEnum = { + Block: 'block', + Allow: 'allow' +} as const; + +export type RequestsCustomRuleGroupUpdateActionEnum = typeof RequestsCustomRuleGroupUpdateActionEnum[keyof typeof RequestsCustomRuleGroupUpdateActionEnum]; +export const RequestsCustomRuleGroupUpdateOperationEnum = { + Add: 'add', + Replace: 'replace', + Remove: 'remove', + Move: 'move' +} as const; + +export type RequestsCustomRuleGroupUpdateOperationEnum = typeof RequestsCustomRuleGroupUpdateOperationEnum[keyof typeof RequestsCustomRuleGroupUpdateOperationEnum]; + +/** + * + * @export + * @interface RequestsCustomRuleGroupUpdates + */ +export interface RequestsCustomRuleGroupUpdates { + /** + * + * @type {Array} + * @memberof RequestsCustomRuleGroupUpdates + */ + 'updates': Array; +} /** * * @export @@ -2125,6 +2265,19 @@ export interface RequestsProfileUpdates { */ 'updates': Array; } +/** + * + * @export + * @interface RequestsReorderProfileCustomRulesBody + */ +export interface RequestsReorderProfileCustomRulesBody { + /** + * + * @type {Array} + * @memberof RequestsReorderProfileCustomRulesBody + */ + 'order': Array; +} /** * * @export @@ -2164,6 +2317,52 @@ export interface RequestsTotpReq { */ 'otp': string; } +/** + * + * @export + * @interface RequestsUpdateProfileCustomRuleBody + */ +export interface RequestsUpdateProfileCustomRuleBody { + /** + * + * @type {string} + * @memberof RequestsUpdateProfileCustomRuleBody + */ + 'action'?: RequestsUpdateProfileCustomRuleBodyActionEnum; + /** + * + * @type {string} + * @memberof RequestsUpdateProfileCustomRuleBody + */ + 'group'?: string; + /** + * + * @type {string} + * @memberof RequestsUpdateProfileCustomRuleBody + */ + 'note'?: string; + /** + * + * @type {number} + * @memberof RequestsUpdateProfileCustomRuleBody + */ + 'order'?: number; + /** + * + * @type {string} + * @memberof RequestsUpdateProfileCustomRuleBody + */ + 'value'?: string; +} + +export const RequestsUpdateProfileCustomRuleBodyActionEnum = { + Block: 'block', + Allow: 'allow', + Comment: 'comment' +} as const; + +export type RequestsUpdateProfileCustomRuleBodyActionEnum = typeof RequestsUpdateProfileCustomRuleBodyActionEnum[keyof typeof RequestsUpdateProfileCustomRuleBodyActionEnum]; + /** * * @export @@ -4724,6 +4923,46 @@ export const ProfileApiAxiosParamCreator = function (configuration?: Configurati options: localVarRequestOptions, }; }, + /** + * Apply JSON-Patch-style operations to the custom-rule group registry. Group names travel in the JSON-Pointer path/from (never the URL). operation=add|replace sets a group\'s note (creating it); remove deletes a group (its rules move to Ungrouped, not deleted); move renames from->path. + * @summary Update profile custom rule groups + * @param {string} id Profile ID + * @param {RequestsCustomRuleGroupUpdates} body Group operations + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1ProfilesIdCustomRuleGroupsPatch: async (id: string, body: RequestsCustomRuleGroupUpdates, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('apiV1ProfilesIdCustomRuleGroupsPatch', 'id', id) + // verify required parameter 'body' is not null or undefined + assertParamExists('apiV1ProfilesIdCustomRuleGroupsPatch', 'body', body) + const localVarPath = `/api/v1/profiles/{id}/custom_rule_groups` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Create up to 20 custom rules for a profile in a single request * @summary Create profile custom rules (batch) @@ -4802,6 +5041,46 @@ export const ProfileApiAxiosParamCreator = function (configuration?: Configurati options: localVarRequestOptions, }; }, + /** + * Set the display order of a profile\'s custom rules. Order is organizational only and does not affect filtering precedence. + * @summary Reorder profile custom rules + * @param {string} id Profile ID + * @param {RequestsReorderProfileCustomRulesBody} body Ordered rule IDs + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1ProfilesIdCustomRulesOrderPatch: async (id: string, body: RequestsReorderProfileCustomRulesBody, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('apiV1ProfilesIdCustomRulesOrderPatch', 'id', id) + // verify required parameter 'body' is not null or undefined + assertParamExists('apiV1ProfilesIdCustomRulesOrderPatch', 'body', body) + const localVarPath = `/api/v1/profiles/{id}/custom_rules/order` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Create profile custom rule * @summary Create profile custom rule @@ -5096,6 +5375,50 @@ export const ProfileApiAxiosParamCreator = function (configuration?: Configurati + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Partially update a single custom rule in place (value, action, note, group, order) + * @summary Update profile custom rule + * @param {string} profileId Profile ID + * @param {string} customRuleId Custom rule ID + * @param {RequestsUpdateProfileCustomRuleBody} body Update custom rule request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch: async (profileId: string, customRuleId: string, body: RequestsUpdateProfileCustomRuleBody, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'profileId' is not null or undefined + assertParamExists('apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch', 'profileId', profileId) + // verify required parameter 'customRuleId' is not null or undefined + assertParamExists('apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch', 'customRuleId', customRuleId) + // verify required parameter 'body' is not null or undefined + assertParamExists('apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch', 'body', body) + const localVarPath = `/api/v1/profiles/{profile_id}/custom_rules/{custom_rule_id}` + .replace(`{${"profile_id"}}`, encodeURIComponent(String(profileId))) + .replace(`{${"custom_rule_id"}}`, encodeURIComponent(String(customRuleId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + localVarHeaderParameter['Content-Type'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -5171,6 +5494,20 @@ export const ProfileApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ProfileApi.apiV1ProfilesIdBlocklistsPost']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Apply JSON-Patch-style operations to the custom-rule group registry. Group names travel in the JSON-Pointer path/from (never the URL). operation=add|replace sets a group\'s note (creating it); remove deletes a group (its rules move to Ungrouped, not deleted); move renames from->path. + * @summary Update profile custom rule groups + * @param {string} id Profile ID + * @param {RequestsCustomRuleGroupUpdates} body Group operations + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiV1ProfilesIdCustomRuleGroupsPatch(id: string, body: RequestsCustomRuleGroupUpdates, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1ProfilesIdCustomRuleGroupsPatch(id, body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ProfileApi.apiV1ProfilesIdCustomRuleGroupsPatch']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Create up to 20 custom rules for a profile in a single request * @summary Create profile custom rules (batch) @@ -5199,6 +5536,20 @@ export const ProfileApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ProfileApi.apiV1ProfilesIdCustomRulesCustomRuleIdDelete']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Set the display order of a profile\'s custom rules. Order is organizational only and does not affect filtering precedence. + * @summary Reorder profile custom rules + * @param {string} id Profile ID + * @param {RequestsReorderProfileCustomRulesBody} body Ordered rule IDs + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiV1ProfilesIdCustomRulesOrderPatch(id: string, body: RequestsReorderProfileCustomRulesBody, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1ProfilesIdCustomRulesOrderPatch(id, body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ProfileApi.apiV1ProfilesIdCustomRulesOrderPatch']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Create profile custom rule * @summary Create profile custom rule @@ -5308,6 +5659,21 @@ export const ProfileApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ProfileApi.apiV1ProfilesPost']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Partially update a single custom rule in place (value, action, note, group, order) + * @summary Update profile custom rule + * @param {string} profileId Profile ID + * @param {string} customRuleId Custom rule ID + * @param {RequestsUpdateProfileCustomRuleBody} body Update custom rule request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch(profileId: string, customRuleId: string, body: RequestsUpdateProfileCustomRuleBody, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch(profileId, customRuleId, body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ProfileApi.apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, } }; @@ -5359,6 +5725,17 @@ export const ProfileApiFactory = function (configuration?: Configuration, basePa apiV1ProfilesIdBlocklistsPost(id: string, blocklistIds: ApiBlocklistsUpdates, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.apiV1ProfilesIdBlocklistsPost(id, blocklistIds, options).then((request) => request(axios, basePath)); }, + /** + * Apply JSON-Patch-style operations to the custom-rule group registry. Group names travel in the JSON-Pointer path/from (never the URL). operation=add|replace sets a group\'s note (creating it); remove deletes a group (its rules move to Ungrouped, not deleted); move renames from->path. + * @summary Update profile custom rule groups + * @param {string} id Profile ID + * @param {RequestsCustomRuleGroupUpdates} body Group operations + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1ProfilesIdCustomRuleGroupsPatch(id: string, body: RequestsCustomRuleGroupUpdates, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiV1ProfilesIdCustomRuleGroupsPatch(id, body, options).then((request) => request(axios, basePath)); + }, /** * Create up to 20 custom rules for a profile in a single request * @summary Create profile custom rules (batch) @@ -5381,6 +5758,17 @@ export const ProfileApiFactory = function (configuration?: Configuration, basePa apiV1ProfilesIdCustomRulesCustomRuleIdDelete(id: string, customRuleId: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.apiV1ProfilesIdCustomRulesCustomRuleIdDelete(id, customRuleId, options).then((request) => request(axios, basePath)); }, + /** + * Set the display order of a profile\'s custom rules. Order is organizational only and does not affect filtering precedence. + * @summary Reorder profile custom rules + * @param {string} id Profile ID + * @param {RequestsReorderProfileCustomRulesBody} body Ordered rule IDs + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1ProfilesIdCustomRulesOrderPatch(id: string, body: RequestsReorderProfileCustomRulesBody, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiV1ProfilesIdCustomRulesOrderPatch(id, body, options).then((request) => request(axios, basePath)); + }, /** * Create profile custom rule * @summary Create profile custom rule @@ -5466,6 +5854,18 @@ export const ProfileApiFactory = function (configuration?: Configuration, basePa apiV1ProfilesPost(body: ApiCreateProfileBody, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.apiV1ProfilesPost(body, options).then((request) => request(axios, basePath)); }, + /** + * Partially update a single custom rule in place (value, action, note, group, order) + * @summary Update profile custom rule + * @param {string} profileId Profile ID + * @param {string} customRuleId Custom rule ID + * @param {RequestsUpdateProfileCustomRuleBody} body Update custom rule request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch(profileId: string, customRuleId: string, body: RequestsUpdateProfileCustomRuleBody, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch(profileId, customRuleId, body, options).then((request) => request(axios, basePath)); + }, }; }; @@ -5525,6 +5925,19 @@ export class ProfileApi extends BaseAPI { return ProfileApiFp(this.configuration).apiV1ProfilesIdBlocklistsPost(id, blocklistIds, options).then((request) => request(this.axios, this.basePath)); } + /** + * Apply JSON-Patch-style operations to the custom-rule group registry. Group names travel in the JSON-Pointer path/from (never the URL). operation=add|replace sets a group\'s note (creating it); remove deletes a group (its rules move to Ungrouped, not deleted); move renames from->path. + * @summary Update profile custom rule groups + * @param {string} id Profile ID + * @param {RequestsCustomRuleGroupUpdates} body Group operations + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProfileApi + */ + public apiV1ProfilesIdCustomRuleGroupsPatch(id: string, body: RequestsCustomRuleGroupUpdates, options?: RawAxiosRequestConfig) { + return ProfileApiFp(this.configuration).apiV1ProfilesIdCustomRuleGroupsPatch(id, body, options).then((request) => request(this.axios, this.basePath)); + } + /** * Create up to 20 custom rules for a profile in a single request * @summary Create profile custom rules (batch) @@ -5551,6 +5964,19 @@ export class ProfileApi extends BaseAPI { return ProfileApiFp(this.configuration).apiV1ProfilesIdCustomRulesCustomRuleIdDelete(id, customRuleId, options).then((request) => request(this.axios, this.basePath)); } + /** + * Set the display order of a profile\'s custom rules. Order is organizational only and does not affect filtering precedence. + * @summary Reorder profile custom rules + * @param {string} id Profile ID + * @param {RequestsReorderProfileCustomRulesBody} body Ordered rule IDs + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProfileApi + */ + public apiV1ProfilesIdCustomRulesOrderPatch(id: string, body: RequestsReorderProfileCustomRulesBody, options?: RawAxiosRequestConfig) { + return ProfileApiFp(this.configuration).apiV1ProfilesIdCustomRulesOrderPatch(id, body, options).then((request) => request(this.axios, this.basePath)); + } + /** * Create profile custom rule * @summary Create profile custom rule @@ -5651,6 +6077,20 @@ export class ProfileApi extends BaseAPI { public apiV1ProfilesPost(body: ApiCreateProfileBody, options?: RawAxiosRequestConfig) { return ProfileApiFp(this.configuration).apiV1ProfilesPost(body, options).then((request) => request(this.axios, this.basePath)); } + + /** + * Partially update a single custom rule in place (value, action, note, group, order) + * @summary Update profile custom rule + * @param {string} profileId Profile ID + * @param {string} customRuleId Custom rule ID + * @param {RequestsUpdateProfileCustomRuleBody} body Update custom rule request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProfileApi + */ + public apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch(profileId: string, customRuleId: string, body: RequestsUpdateProfileCustomRuleBody, options?: RawAxiosRequestConfig) { + return ProfileApiFp(this.configuration).apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch(profileId, customRuleId, body, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/app/src/pages/custom_rules/CustomRulesCard.tsx b/app/src/pages/custom_rules/CustomRulesCard.tsx index 9df41dca..69435aaf 100644 --- a/app/src/pages/custom_rules/CustomRulesCard.tsx +++ b/app/src/pages/custom_rules/CustomRulesCard.tsx @@ -1,15 +1,80 @@ -import { useCallback, useState, type JSX } from "react"; +import { useCallback, useEffect, useMemo, useState, type JSX, type ReactNode } from "react"; +import { + DndContext, + DragOverlay, + closestCorners, + KeyboardSensor, + PointerSensor, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Card } from "@/components/ui/card"; -import { MinusIcon, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Check, + Folder, + FolderOpen, + FolderPlus, + GripVertical, + MinusIcon, + MoreVertical, + Pencil, + StickyNote, + Trash2, + X, +} from "lucide-react"; import type { ModelCustomRule } from "@/api/client/api"; import NoRulesExist from "@/pages/custom_rules/NoRulesExist"; import CustomRuleEntry from "@/pages/custom_rules/Entry"; +const UNGROUPED = ""; +const CONTAINER_PREFIX = "group:"; +const NEW_GROUP_ZONE = "group:__new__"; + +const containerId = (group: string) => `${CONTAINER_PREFIX}${group}`; +const isContainerId = (id: string) => id.startsWith(CONTAINER_PREFIX); +const groupFromContainer = (id: string) => id.slice(CONTAINER_PREFIX.length); + export interface CustomRulesCardProps { rules: ModelCustomRule[]; + groupNotes: Record; selectedIds: string[]; onCheck: (id: string, checked: boolean) => void; onDelete: (id: string) => void | Promise; + onEdit: (rule: ModelCustomRule) => void; + onReorder: (orderedIds: string[]) => void | Promise; + onMoveRule: (orderedIds: string[], ruleId: string, newGroup: string) => void | Promise; + onSaveGroupNote: (group: string, note: string | null) => void | Promise; + onCreateGroup: (name: string) => void | Promise; + onRenameGroup: (from: string, to: string) => void | Promise; + onDeleteGroup: (name: string) => void | Promise; allSelected: boolean; selectedCount: number; handleBulkDelete: () => void | Promise; @@ -18,11 +83,313 @@ export interface CustomRulesCardProps { searchQuery: string; } +// ── Row ────────────────────────────────────────────────────────────────────── +// A sortable rule row. The grip handle stays hidden until the row is hovered so +// the list reads clean at rest, then invites the drag on approach. +function SortableEntry({ + rule, + checked, + onCheck, + onDelete, + onEdit, + isRemoving, + hideDeleteButton, + draggable, +}: { + rule: ModelCustomRule; + checked: boolean; + onCheck: (id: string, checked: boolean) => void; + onDelete: (id: string) => void; + onEdit: (rule: ModelCustomRule) => void; + isRemoving: boolean; + hideDeleteButton: boolean; + draggable: boolean; +}): JSX.Element { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: rule.id, + disabled: !draggable, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + // The original row becomes a faint placeholder while its overlay clone is dragged. + opacity: isDragging ? 0.35 : 1, + }; + + const handle = draggable ? ( + + ) : undefined; + + return ( +
+ +
+ ); +} + +// ── Droppable section body ───────────────────────────────────────────────────── +// Wraps a group's rows so empty/collapsed groups still accept a drop, and lights +// up in the accent colour the instant a dragged row is over it. +function DroppableSection({ + group, + isEmpty, + children, +}: { + group: string; + isEmpty: boolean; + children: ReactNode; +}): JSX.Element { + const { setNodeRef, isOver } = useDroppable({ id: containerId(group) }); + + return ( +
+ {isEmpty ? Drop rules here : children} +
+ ); +} + +// ── Group header ─────────────────────────────────────────────────────────────── +function GroupHeader({ + name, + count, + collapsed, + onToggle, + note, + onSaveNote, + onRename, + onDelete, + loading, +}: { + name: string; + count: number; + collapsed: boolean; + onToggle: () => void; + note: string; + onSaveNote: (note: string | null) => void; + onRename: (to: string) => void; + onDelete: () => void; + loading: boolean; +}): JSX.Element { + const [editingNote, setEditingNote] = useState(false); + const [noteDraft, setNoteDraft] = useState(note); + const [renaming, setRenaming] = useState(false); + const [nameDraft, setNameDraft] = useState(name); + + useEffect(() => { setNoteDraft(note); }, [note]); + useEffect(() => { setNameDraft(name); }, [name]); + + const commitNote = () => { onSaveNote(noteDraft.trim() === "" ? null : noteDraft.trim()); setEditingNote(false); }; + const cancelNote = () => { setNoteDraft(note); setEditingNote(false); }; + const commitName = () => { + const next = nameDraft.trim(); + if (next && next !== name) onRename(next); + setRenaming(false); + }; + const cancelName = () => { setNameDraft(name); setRenaming(false); }; + + if (renaming) { + return ( +
+ setNameDraft(e.target.value.slice(0, 64))} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); commitName(); } + else if (e.key === "Escape") { e.preventDefault(); cancelName(); } + }} + className="h-10 md:h-7 w-56 max-w-full" + autoFocus + /> + + +
+ ); + } + + return ( +
+
+ + + {editingNote ? ( +
+ setNoteDraft(e.target.value.slice(0, 80))} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); commitNote(); } + else if (e.key === "Escape") { e.preventDefault(); cancelNote(); } + }} + placeholder="Group note" + className="h-10 md:h-7 w-48 max-w-full" + autoFocus + /> + + +
+ ) : ( + /* modal={false} so Radix never sets pointer-events:none on . + The Delete item opens a confirm Dialog whose confirmation refetches + the profile and unmounts this header (and this menu). A modal menu + unmounted mid-close never runs its body-unlock cleanup, leaving the + whole app frozen. Non-modal has no body lock, so the freeze cannot occur. */ + + + + + + setRenaming(true)}> + Rename group + + setEditingNote(true)}> + {note ? "Edit comment" : "Add comment"} + + + Delete group + + + + )} +
+ + {/* Group comment as an always-visible muted line under the name (indented + to align past the folder icon). Real text — no hover/tooltip. */} + {note && !editingNote && ( +
+ {note} +
+ )} +
+ ); +} + +// ── Create-group zone (button + create-on-drop target) ───────────────────────── +function NewGroupZone({ + creating, + onStartCreate, + onCancelCreate, + onConfirmCreate, + namingForDrop, +}: { + creating: boolean; + onStartCreate: () => void; + onCancelCreate: () => void; + onConfirmCreate: (name: string) => void; + namingForDrop: boolean; +}): JSX.Element { + const { setNodeRef, isOver } = useDroppable({ id: NEW_GROUP_ZONE }); + const [draft, setDraft] = useState(""); + + useEffect(() => { if (!creating) setDraft(""); }, [creating]); + + if (creating) { + return ( +
+ + setDraft(e.target.value.slice(0, 64))} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); if (draft.trim()) onConfirmCreate(draft.trim()); } + else if (e.key === "Escape") { e.preventDefault(); onCancelCreate(); } + }} + placeholder={namingForDrop ? "Name the new group for this rule…" : "New group name…"} + className="h-10 md:h-7 w-56 max-w-full" + autoFocus + /> + + +
+ ); + } + + return ( + + ); +} + export default function CustomRulesCard({ rules, + groupNotes, selectedIds, onCheck, onDelete, + onEdit, + onReorder, + onMoveRule, + onSaveGroupNote, + onCreateGroup, + onRenameGroup, + onDeleteGroup, allSelected, selectedCount, handleBulkDelete, @@ -31,6 +398,20 @@ export default function CustomRulesCard({ searchQuery, }: CustomRulesCardProps): JSX.Element { const [removingIds, setRemovingIds] = useState([]); + const [collapsed, setCollapsed] = useState>(new Set()); + const [activeId, setActiveId] = useState(null); + const [creatingGroup, setCreatingGroup] = useState(false); + // When a row is dropped on the "new group" zone we hold its id and prompt for a name. + const [pendingDropRuleId, setPendingDropRuleId] = useState(null); + // Group pending deletion confirmation (styled dialog instead of window.confirm). + const [groupToDelete, setGroupToDelete] = useState(null); + + const sortedFromProps = useMemo( + () => [...rules].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), + [rules], + ); + const [localRules, setLocalRules] = useState(sortedFromProps); + useEffect(() => { setLocalRules(sortedFromProps); }, [sortedFromProps]); const handleEntryDelete = useCallback((id: string) => { setRemovingIds(prev => [...prev, id]); @@ -40,6 +421,145 @@ export default function CustomRulesCard({ }, 300); }, [onDelete]); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // Group existence = union of the registry (groupNotes) and any rule's label. + const groupNames = useMemo(() => { + const set = new Set(); + for (const r of localRules) { const g = r.group ?? ""; if (g !== "") set.add(g); } + for (const k of Object.keys(groupNotes)) { if (k !== "") set.add(k); } + return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + }, [localRules, groupNotes]); + + // Sections: Ungrouped first, then named groups alphabetically. Empty groups included. + const sections = useMemo(() => { + const byGroup = new Map(); + byGroup.set(UNGROUPED, []); + for (const g of groupNames) byGroup.set(g, []); + for (const r of localRules) { + const g = r.group ?? UNGROUPED; + if (!byGroup.has(g)) byGroup.set(g, []); + byGroup.get(g)!.push(r); + } + const ordered = [{ name: UNGROUPED, items: byGroup.get(UNGROUPED)! }]; + for (const g of groupNames) ordered.push({ name: g, items: byGroup.get(g)! }); + return ordered; + }, [localRules, groupNames]); + + const groupOf = useCallback( + (id: string) => localRules.find(r => r.id === id)?.group ?? UNGROUPED, + [localRules], + ); + + const draggable = searchQuery.trim().length === 0 && !allSelected && !loading; + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(String(event.active.id)); + }, []); + + // Live cross-section move: when the row hovers a different group, relabel it and + // splice it into that group so the UI reflects the move mid-drag. + const handleDragOver = useCallback((event: DragOverEvent) => { + const { active, over } = event; + if (!over) return; + const activeKey = String(active.id); + const overKey = String(over.id); + if (overKey === NEW_GROUP_ZONE) return; // resolved on drop + + const targetGroup = isContainerId(overKey) ? groupFromContainer(overKey) : groupOf(overKey); + const activeGroup = groupOf(activeKey); + if (targetGroup === activeGroup) return; + + setLocalRules(prev => { + const activeIdx = prev.findIndex(r => r.id === activeKey); + if (activeIdx < 0) return prev; + const next = [...prev]; + const [moved] = next.splice(activeIdx, 1); + const relabelled = { ...moved, group: targetGroup === UNGROUPED ? "" : targetGroup }; + + let insertIdx: number; + if (isContainerId(overKey)) { + // Append to the end of the target group. + let last = -1; + next.forEach((r, i) => { if ((r.group ?? "") === (targetGroup === UNGROUPED ? "" : targetGroup)) last = i; }); + insertIdx = last + 1; + } else { + insertIdx = next.findIndex(r => r.id === overKey); + if (insertIdx < 0) insertIdx = next.length; + } + next.splice(insertIdx, 0, relabelled); + return next; + }); + }, [groupOf]); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + const activeKey = String(active.id); + setActiveId(null); + + if (!over) { setLocalRules(sortedFromProps); return; } + const overKey = String(over.id); + + // Dropped on the create-group zone: revert the visual and prompt for a name. + if (overKey === NEW_GROUP_ZONE) { + setLocalRules(sortedFromProps); + setPendingDropRuleId(activeKey); + setCreatingGroup(true); + return; + } + + // Same-list reorder finalisation. + let next = localRules; + if (!isContainerId(overKey) && activeKey !== overKey) { + const oldIndex = localRules.findIndex(r => r.id === activeKey); + const newIndex = localRules.findIndex(r => r.id === overKey); + if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { + next = arrayMove(localRules, oldIndex, newIndex); + setLocalRules(next); + } + } + + const originalGroup = rules.find(r => r.id === activeKey)?.group ?? ""; + const finalGroup = next.find(r => r.id === activeKey)?.group ?? ""; + const orderedIds = next.map(r => r.id); + + if (finalGroup !== originalGroup) { + void onMoveRule(orderedIds, activeKey, finalGroup); + } else { + // Only persist if order actually changed. + const prevIds = sortedFromProps.map(r => r.id); + if (orderedIds.join("|") !== prevIds.join("|")) void onReorder(orderedIds); + } + }, [localRules, rules, sortedFromProps, onMoveRule, onReorder]); + + const confirmNewGroup = useCallback((name: string) => { + if (pendingDropRuleId) { + // Create-on-drop: assigning the rule's group makes the group exist. + const orderedIds = localRules.map(r => r.id); + void onMoveRule(orderedIds, pendingDropRuleId, name); + setPendingDropRuleId(null); + } else { + void onCreateGroup(name); + } + setCreatingGroup(false); + }, [pendingDropRuleId, localRules, onMoveRule, onCreateGroup]); + + const cancelNewGroup = useCallback(() => { + setCreatingGroup(false); + setPendingDropRuleId(null); + }, []); + + const confirmDeleteGroup = useCallback(() => { + if (groupToDelete !== null) void onDeleteGroup(groupToDelete); + setGroupToDelete(null); + }, [groupToDelete, onDeleteGroup]); + + const activeRule = activeId ? localRules.find(r => r.id === activeId) : null; + const hasNamedGroups = groupNames.length > 0; + if (rules.length === 0) { if (searchQuery.trim().length > 0) { return ( @@ -88,21 +608,136 @@ export default function CustomRulesCard({ )} - {rules.map((rule) => { - const checked = selectedIds.includes(rule.id); - const isRemoving = removingIds.includes(rule.id); - return ( - - ); - })} + + +
+ {sections.map((section) => { + const isCollapsed = collapsed.has(section.name); + const sectionIds = section.items.map(r => r.id); + const isEmpty = section.items.length === 0; + // Hide the empty Ungrouped section unless named groups exist (so it can + // serve as a "remove from group" target). + if (section.name === UNGROUPED && isEmpty && !hasNamedGroups) return null; + + return ( +
+ {section.name !== UNGROUPED ? ( + setCollapsed(prev => { + const next = new Set(prev); + if (next.has(section.name)) next.delete(section.name); + else next.add(section.name); + return next; + })} + note={groupNotes[section.name] ?? ""} + onSaveNote={(n) => onSaveGroupNote(section.name, n)} + onRename={(to) => onRenameGroup(section.name, to)} + onDelete={() => setGroupToDelete(section.name)} + loading={loading} + /> + ) : ( + hasNamedGroups && ( +
+ Ungrouped +
+ ) + )} + {/* grid-template-rows 0fr<->1fr animates the section height + fluidly without measuring; the inner wrapper clips during + the transition. Kept mounted so it stays a drop target. */} +
+
+ + + {section.items.map((rule) => ( + + ))} + + +
+
+
+ ); + })} + + {draggable && ( + setCreatingGroup(true)} + onCancelCreate={cancelNewGroup} + onConfirmCreate={confirmNewGroup} + /> + )} +
+ + + {activeRule ? ( +
+ {}} + onDelete={() => {}} + isRemoving={false} + hideDeleteButton + /> +
+ ) : null} +
+
+ + { if (!open) setGroupToDelete(null); }}> + + + Delete group + + Delete the group “{groupToDelete}”? Its rules move to Ungrouped — they are not deleted. + + + + + + + + ); } diff --git a/app/src/pages/custom_rules/Entry.tsx b/app/src/pages/custom_rules/Entry.tsx index 05ce27dd..1b26c303 100644 --- a/app/src/pages/custom_rules/Entry.tsx +++ b/app/src/pages/custom_rules/Entry.tsx @@ -1,9 +1,8 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, type ReactNode } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; -// import { Switch } from "@/components/ui/switch"; -import { Trash2 } from "lucide-react"; +import { Pencil, Trash2 } from "lucide-react"; import type { ModelCustomRule } from "@/api/client/api"; interface CustomRuleEntryProps { @@ -11,8 +10,12 @@ interface CustomRuleEntryProps { checked: boolean; onCheck: (id: string, checked: boolean) => void; onDelete: (id: string) => void; + onEdit?: (rule: ModelCustomRule) => void; isRemoving: boolean; hideDeleteButton?: boolean; + // dragHandle, when provided, is rendered at the start of the row (a grip the + // user drags to reorder). The sortable wrapper owns the drag mechanics. + dragHandle?: ReactNode; } const CustomRuleEntry: React.FC = ({ @@ -20,8 +23,10 @@ const CustomRuleEntry: React.FC = ({ checked, onCheck, onDelete, + onEdit, isRemoving, hideDeleteButton = false, + dragHandle, }) => { const [isVisible, setIsVisible] = useState(false); @@ -32,33 +37,48 @@ const CustomRuleEntry: React.FC = ({ const domain = rule.value?.replace(/\.$/, "") ?? ""; + const note = rule.note?.trim() ?? ""; return ( -
+
+ {dragHandle} onCheck(rule.id, Boolean(val))} className="w-4 h-4 border-solid border-[var(--tailwind-colors-rdns-600)]" /> -
-
+ {/* Domain + always-visible note. The note is real text in reading order + (no hover/tooltip), so it works on touch, keyboard, and screen readers; + it clamps to 2 lines (notes are capped at 80 chars, so this rarely clips). */} +
+
{domain}
+ {note && ( +
+ {note} +
+ )}
-
- - {/* Switch for enabling/disabling rules - not implemented yet - - */} +
+ {!hideDeleteButton && onEdit && ( + + )} {!hideDeleteButton && ( @@ -77,4 +98,4 @@ const CustomRuleEntry: React.FC = ({ ); }; -export default CustomRuleEntry; \ No newline at end of file +export default CustomRuleEntry; diff --git a/app/src/pages/custom_rules/MainContentSection.tsx b/app/src/pages/custom_rules/MainContentSection.tsx index ed3a8fbe..2ae5ade9 100644 --- a/app/src/pages/custom_rules/MainContentSection.tsx +++ b/app/src/pages/custom_rules/MainContentSection.tsx @@ -11,14 +11,21 @@ import LimitedAccessBanner from "@/components/LimitedAccessBanner"; import BetaEndingBanner from "@/components/BetaEndingBanner"; import api from "@/api/api"; import { toast } from "sonner"; -import type { ModelAccount, ModelCustomRule, ModelProfile, ResponsesCustomRuleBatchSkipped } from "@/api/client/api"; +import type { ModelAccount, ModelCustomRule, ModelCustomRuleGroup, ModelProfile, ResponsesCustomRuleBatchSkipped } from "@/api/client/api"; import { RuleComposer, type RuleOption } from "@/pages/custom_rules/RuleComposer"; import CustomRulesCard from "@/pages/custom_rules/CustomRulesCard"; +import RuleEditDialog from "@/pages/custom_rules/RuleEditDialog"; +import type { RequestsUpdateProfileCustomRuleBody, RequestsCustomRuleGroupUpdate } from "@/api/client/api"; +import { RequestsCustomRuleGroupUpdateOperationEnum as GroupOp } from "@/api/client/api"; import CustomRulesExportLimitBanner from "@/pages/custom_rules/CustomRulesExportLimitBanner"; import { formatApiError } from "@/lib/apiError"; type RuleTab = "denylist" | "allowlist"; +// toPointer encodes a group name as a single-segment RFC6901 JSON Pointer so it can +// travel in the group-ops request body (never the URL). Escape ~ before /. +const toPointer = (name: string): string => `/${name.replace(/~/g, "~0").replace(/\//g, "~1")}`; + const TAB_TO_ACTION: Record = { denylist: "block", allowlist: "allow", @@ -40,6 +47,7 @@ export default function MainContentSection({ profiles = [] }: Omit([]); + const [editingRule, setEditingRule] = useState(null); const [composerTokens, setComposerTokens] = useState>({ denylist: [], allowlist: [], @@ -63,7 +71,10 @@ export default function MainContentSection({ profiles = [] }: Omit activeProfile?.settings?.custom_rules ?? [], + [activeProfile?.settings?.custom_rules], + ); const denylist = customRules.filter(rule => rule.action === "block"); const allowlist = customRules.filter(rule => rule.action === "allow"); const denylistHasRules = denylist.length > 0; @@ -81,13 +92,15 @@ export default function MainContentSection({ profiles = [] }: Omit { + const handleComposerSubmit = useCallback(async (tab: RuleTab, tokensOverride?: RuleOption[]) => { if (!activeProfile?.profile_id) { toast.error("Select a profile before adding custom rules."); return; } - const originalTokens = composerTokens[tab]; + // tokensOverride is supplied by the Add button so a value typed-but-not-yet- + // chipped is included without waiting for a parent re-render. + const originalTokens = tokensOverride ?? composerTokens[tab]; const staticTokens = originalTokens.filter(token => token.meta?.error); const submissionTokens = originalTokens.filter(token => !token.meta?.error); @@ -220,6 +233,157 @@ export default function MainContentSection({ profiles = [] }: Omit { + if (!activeProfile?.profile_id) return; + if (Object.keys(patch).length === 0) { + setEditingRule(null); + return; + } + setLoading(true); + try { + await api.Client.profilesApi.apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch( + activeProfile.profile_id, + ruleId, + patch, + ); + const updated = await api.Client.profilesApi.apiV1ProfilesIdGet(activeProfile.profile_id); + setActiveProfile(updated.data); + toast.success("Rule updated."); + setEditingRule(null); + } catch (error: unknown) { + toast.error(formatApiError(error, "Failed to update rule")); + } finally { + setLoading(false); + } + }, [activeProfile?.profile_id, setActiveProfile]); + + // Persist a drag-reorder. The card sends the active tab's full ordered IDs; we + // build the complete per-profile order (other tab keeps its order) so the + // backend renumbers everything without cross-tab collisions. + const handleReorder = useCallback(async (tab: RuleTab, orderedIdsForTab: string[]) => { + if (!activeProfile?.profile_id) return; + const byOrder = (a: ModelCustomRule, b: ModelCustomRule) => (a.order ?? 0) - (b.order ?? 0); + const denyIds = tab === "denylist" ? orderedIdsForTab : denylist.slice().sort(byOrder).map(r => r.id); + const allowIds = tab === "allowlist" ? orderedIdsForTab : allowlist.slice().sort(byOrder).map(r => r.id); + const fullOrder = [...denyIds, ...allowIds]; + try { + await api.Client.profilesApi.apiV1ProfilesIdCustomRulesOrderPatch( + activeProfile.profile_id, + { order: fullOrder }, + ); + const updated = await api.Client.profilesApi.apiV1ProfilesIdGet(activeProfile.profile_id); + setActiveProfile(updated.data); + } catch (error: unknown) { + toast.error(formatApiError(error, "Failed to reorder rules")); + // Revert optimistic ordering by refetching the persisted state. + const updated = await api.Client.profilesApi.apiV1ProfilesIdGet(activeProfile.profile_id); + setActiveProfile(updated.data); + } + }, [activeProfile?.profile_id, denylist, allowlist, setActiveProfile]); + + // All group-registry mutations go through one JSON-Patch endpoint. Group names + // travel in the JSON-Pointer path/from (RFC6901), never the URL. Reverts via + // refetch on error. + const applyGroupOps = useCallback(async ( + ops: RequestsCustomRuleGroupUpdate[], + failMsg: string, + successMsg?: string, + ) => { + if (!activeProfile?.profile_id) return; + try { + await api.Client.profilesApi.apiV1ProfilesIdCustomRuleGroupsPatch( + activeProfile.profile_id, { updates: ops }, + ); + const updated = await api.Client.profilesApi.apiV1ProfilesIdGet(activeProfile.profile_id); + setActiveProfile(updated.data); + if (successMsg) toast.success(successMsg); + } catch (error: unknown) { + toast.error(formatApiError(error, failMsg)); + } + }, [activeProfile?.profile_id, setActiveProfile]); + + // Save (or clear) a group note in a specific list. A cleared note keeps the + // group (replace ""). + const handleGroupNote = useCallback((action: "block" | "allow", group: string, note: string | null) => { + void applyGroupOps( + [{ operation: GroupOp.Replace, action, path: toPointer(group), value: note ?? "" }], + "Failed to save group note", + ); + }, [applyGroupOps]); + + // Move a rule to another group (drag): set its group, then renumber the tab's + // full order, then a single refetch. Reverts via refetch on error. + const handleMoveRule = useCallback(async (tab: RuleTab, orderedIdsForTab: string[], ruleId: string, newGroup: string) => { + if (!activeProfile?.profile_id) return; + const byOrder = (a: ModelCustomRule, b: ModelCustomRule) => (a.order ?? 0) - (b.order ?? 0); + const denyIds = tab === "denylist" ? orderedIdsForTab : denylist.slice().sort(byOrder).map(r => r.id); + const allowIds = tab === "allowlist" ? orderedIdsForTab : allowlist.slice().sort(byOrder).map(r => r.id); + const fullOrder = [...denyIds, ...allowIds]; + try { + await api.Client.profilesApi.apiV1ProfilesProfileIdCustomRulesCustomRuleIdPatch( + activeProfile.profile_id, ruleId, { group: newGroup }, + ); + await api.Client.profilesApi.apiV1ProfilesIdCustomRulesOrderPatch( + activeProfile.profile_id, { order: fullOrder }, + ); + const updated = await api.Client.profilesApi.apiV1ProfilesIdGet(activeProfile.profile_id); + setActiveProfile(updated.data); + } catch (error: unknown) { + toast.error(formatApiError(error, "Failed to move rule")); + const updated = await api.Client.profilesApi.apiV1ProfilesIdGet(activeProfile.profile_id); + setActiveProfile(updated.data); + } + }, [activeProfile?.profile_id, denylist, allowlist, setActiveProfile]); + + // Create an empty group in a specific list (registers it in custom_rule_groups). + const handleCreateGroup = useCallback((action: "block" | "allow", name: string) => { + void applyGroupOps( + [{ operation: GroupOp.Add, action, path: toPointer(name), value: "" }], + "Failed to create group", + ); + }, [applyGroupOps]); + + const handleRenameGroup = useCallback((action: "block" | "allow", from: string, to: string) => { + void applyGroupOps( + [{ operation: GroupOp.Move, action, from: toPointer(from), path: toPointer(to) }], + "Failed to rename group", + `Group renamed to "${to}".`, + ); + }, [applyGroupOps]); + + const handleDeleteGroup = useCallback((action: "block" | "allow", name: string) => { + void applyGroupOps( + [{ operation: GroupOp.Remove, action, path: toPointer(name) }], + "Failed to delete group", + `Group "${name}" deleted. Its rules moved to Ungrouped.`, + ); + }, [applyGroupOps]); + + // Per-list group registry. The cards consume a name→comment map, so flatten + // each list's [{name, comment}] into that shape. + const groupRegistry = activeProfile?.settings?.custom_rule_groups; + const toNoteMap = (list?: ModelCustomRuleGroup[]): Record => + Object.fromEntries((list ?? []).map(g => [g.name, g.comment ?? ""])); + const denyGroupNotes = useMemo(() => toNoteMap(groupRegistry?.block), [groupRegistry?.block]); + const allowGroupNotes = useMemo(() => toNoteMap(groupRegistry?.allow), [groupRegistry?.allow]); + + // Groups offered in the edit dialog are scoped to the rule's current list. + const existingGroups = useMemo(() => { + const action = editingRule?.action; + if (action !== "block" && action !== "allow") return []; + const registry = action === "block" ? groupRegistry?.block : groupRegistry?.allow; + return Array.from( + new Set([ + ...customRules + .filter(r => r.action === action) + .map(r => r.group) + .filter((g): g is string => !!g && g.trim() !== ""), + ...(registry ?? []).map(g => g.name).filter(n => n.trim() !== ""), + ]), + ).sort(); + }, [customRules, groupRegistry?.block, groupRegistry?.allow, editingRule?.action]); + // Show header only if at least one is selected const allSelected = selectedIds.length > 0; const selectedCount = selectedIds.length; @@ -316,7 +480,7 @@ export default function MainContentSection({ profiles = [] }: Omit updateComposerTokens(activeTab, next)} - onSubmit={() => handleComposerSubmit(activeTab)} + onSubmit={(override) => handleComposerSubmit(activeTab, override)} loading={loading || !activeProfile?.profile_id || isRestricted} className="flex-1 min-w-0" /> @@ -367,9 +531,17 @@ export default function MainContentSection({ profiles = [] }: Omit { void handleDeleteRule(id); }} + onEdit={setEditingRule} + onReorder={(ids) => handleReorder("denylist", ids)} + onMoveRule={(ids, ruleId, g) => handleMoveRule("denylist", ids, ruleId, g)} + onSaveGroupNote={(group, note) => handleGroupNote("block", group, note)} + onCreateGroup={(name) => handleCreateGroup("block", name)} + onRenameGroup={(from, to) => handleRenameGroup("block", from, to)} + onDeleteGroup={(name) => handleDeleteGroup("block", name)} allSelected={allSelected} selectedCount={selectedCount} handleBulkDelete={handleBulkDelete} @@ -381,9 +553,17 @@ export default function MainContentSection({ profiles = [] }: Omit { void handleDeleteRule(id); }} + onEdit={setEditingRule} + onReorder={(ids) => handleReorder("allowlist", ids)} + onMoveRule={(ids, ruleId, g) => handleMoveRule("allowlist", ids, ruleId, g)} + onSaveGroupNote={(group, note) => handleGroupNote("allow", group, note)} + onCreateGroup={(name) => handleCreateGroup("allow", name)} + onRenameGroup={(from, to) => handleRenameGroup("allow", from, to)} + onDeleteGroup={(name) => handleDeleteGroup("allow", name)} allSelected={allSelected} selectedCount={selectedCount} handleBulkDelete={handleBulkDelete} @@ -397,6 +577,15 @@ export default function MainContentSection({ profiles = [] }: Omit
+ + { if (!open) setEditingRule(null); }} + existingGroups={existingGroups} + loading={loading} + onSave={handleSaveEdit} + />
); } diff --git a/app/src/pages/custom_rules/RuleComposer.tsx b/app/src/pages/custom_rules/RuleComposer.tsx index 7f9067c4..0543863d 100644 --- a/app/src/pages/custom_rules/RuleComposer.tsx +++ b/app/src/pages/custom_rules/RuleComposer.tsx @@ -35,7 +35,7 @@ export interface RuleOption { interface RuleComposerProps { tokens: RuleOption[]; onTokensChange: (next: RuleOption[]) => void; - onSubmit: () => void; + onSubmit: (tokensOverride?: RuleOption[]) => void; loading: boolean; action: "denylist" | "allowlist"; className?: string; @@ -207,9 +207,9 @@ export function RuleComposer({ const hasValidTokens = tokens.some((token) => !token.meta?.error); const availableSlots = MAX_RULES_PER_BATCH - tokens.length; - const addTokens = (rawValues: string[]) => { + const addTokens = (rawValues: string[]): RuleOption[] => { if (rawValues.length === 0) { - return; + return tokens; } const normalizedSet = new Set(); @@ -243,8 +243,9 @@ export function RuleComposer({ slots -= 1; }); + const merged = added.length > 0 ? [...tokens, ...added] : tokens; if (added.length > 0) { - onTokensChange([...tokens, ...added]); + onTokensChange(merged); } if (duplicates.length > 0) { @@ -256,6 +257,19 @@ export function RuleComposer({ } setInputValue(""); + return merged; + }; + + // handleAddClick powers the "+" / "Add" button. It first commits any text the + // user typed but did not yet turn into a chip, then submits the merged list in + // the same click — fixing the long-standing "nothing happens / press Enter + // twice" confusion. + const handleAddClick = () => { + const merged = inputValue.trim() ? addTokens(splitRulesFromInput(inputValue)) : tokens; + const submittable = merged.filter((token) => !token.meta?.error); + if (submittable.length > 0 && !loading) { + onSubmit(merged); + } }; const handleCreateOption = (value: string) => { @@ -338,8 +352,8 @@ export function RuleComposer({
+ +
+ + +
+ +
+ + {existingGroups.length > 0 ? ( + + ) : ( +

+ No groups yet — create one with “New group” on the list, then drag rules in. +

+ )} +
+ +
+ +