Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d295581
feat(api): add note, group and order fields to custom rules
MaciejTe Jun 24, 2026
dd1dcf9
feat(api): add edit, reorder and group-note endpoints for custom rules
MaciejTe Jun 24, 2026
5392b92
feat(api): round-trip custom rule notes and groups through export/import
MaciejTe Jun 24, 2026
28e8696
chore(api): regenerate swagger and TS/Python clients
MaciejTe Jun 24, 2026
d6c3565
feat(app): enrich custom rules page UX
MaciejTe Jun 24, 2026
b4e4422
feat(api): manage custom rule groups via one JSON-Patch endpoint
MaciejTe Jun 24, 2026
0d61a20
chore(api): regenerate swagger and TS/Python clients
MaciejTe Jun 24, 2026
18745b6
feat(app): cross-group drag and group management for custom rules
MaciejTe Jun 24, 2026
f218fca
chore(app): Increase height of New group section
MaciejTe Jun 24, 2026
aa0a810
feat(app): Embed group comment button into dropdown
MaciejTe Jun 25, 2026
a139ecd
feat(app): Groups improvements (transition + mobile view)
MaciejTe Jun 25, 2026
43f85ad
feat(app): Display comments directly in CR card and under group name
MaciejTe Jun 25, 2026
fa20d00
feat(api): Separate groups per Denylist/Allowlist
MaciejTe Jun 25, 2026
2cbd93f
feat(app): Separate groups per Denylist/Allowlist
MaciejTe Jun 25, 2026
1c9ee17
chore(db): Add custom rule enrichment migration
MaciejTe Jun 25, 2026
9b6ce1f
chore(app): Improve dropdown direction
MaciejTe Jun 25, 2026
1b32fbd
chore(api): Improve export/import custom rule groups validation
MaciejTe Jun 25, 2026
5478882
test(e2e): Update Linkedin IPs
MaciejTe Jun 25, 2026
82443f9
chore(app): Standarize buttons look in dialogs
MaciejTe Jun 25, 2026
849becc
chore(app): Don't cover CRs when dropdown is open
MaciejTe Jun 25, 2026
8a94b49
chore(app): Update icon color
MaciejTe Jun 25, 2026
6d91656
chore(db): Update migration number
MaciejTe Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions api/api/custom_rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 ("/<name>")
// 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
Expand Down
3 changes: 2 additions & 1 deletion api/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions api/api/requests/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
5 changes: 5 additions & 0 deletions api/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
Original file line number Diff line number Diff line change
@@ -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" }
}
]
39 changes: 39 additions & 0 deletions api/db/mongodb/migrations/021_profiles_custom_rules_enrich.up.json
Original file line number Diff line number Diff line change
@@ -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" }
}
]
Loading
Loading