package services import ( "encoding/json" "fmt" "net/url" "strings" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/i18nx" "github.com/mlogclub/simple/common/strs" ) type supportNavigationMenuValidator struct{} func (supportNavigationMenuValidator) Validate(raw json.RawMessage) (json.RawMessage, []response.ConfigFieldError, error) { var input []request.SupportNavigationMenuItemRequest if err := json.Unmarshal(raw, &input); err != nil { return nil, []response.ConfigFieldError{configFieldError("", "invalid_json", "error.supportConfig.navigationInvalidJSON")}, nil } items, fieldErrors := normalizeSupportNavigationMenu(input) if len(fieldErrors) > 0 { return nil, fieldErrors, nil } normalized, err := json.Marshal(items) if err != nil { return nil, nil, err } return normalized, nil, nil } func normalizeSupportNavigationMenu(input []request.SupportNavigationMenuItemRequest) ([]response.SupportNavigationMenuItemResponse, []response.ConfigFieldError) { if len(input) == 0 { return nil, []response.ConfigFieldError{configFieldError("navigationMenu", "required", "error.supportConfig.navigationRequired")} } if len(input) > 20 { return nil, []response.ConfigFieldError{configFieldError("navigationMenu", "too_many", "error.supportConfig.navigationTooMany")} } seenIDs := make(map[string]int) items, visibleCount, fieldErrors := normalizeSupportNavigationItems(input, "navigationMenu", 1, seenIDs) if len(fieldErrors) > 0 { return nil, fieldErrors } if visibleCount == 0 { return nil, []response.ConfigFieldError{configFieldError("navigationMenu", "visible_required", "error.supportConfig.navigationVisibleRequired")} } return items, nil } func normalizeSupportNavigationItems(input []request.SupportNavigationMenuItemRequest, path string, depth int, seenIDs map[string]int) ([]response.SupportNavigationMenuItemResponse, int, []response.ConfigFieldError) { items := make([]response.SupportNavigationMenuItemResponse, 0, len(input)) visibleCount := 0 for idx, raw := range input { itemPath := fmt.Sprintf("%s[%d]", path, idx) title := strings.TrimSpace(raw.Title) if title == "" { return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".title", "required", "error.supportConfig.navigationTitleRequired")} } if len([]rune(title)) > 64 { return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".title", "too_long", "error.supportConfig.navigationTitleTooLong")} } link := strings.TrimSpace(raw.URL) if link == "" { return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".url", "required", "error.supportConfig.navigationURLRequired")} } if !isAllowedSupportNavigationURL(link) { return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".url", "invalid_url", "error.supportConfig.navigationURLInvalid")} } id := normalizeSupportNavigationMenuID(raw.ID) if id == "" { id = "nav-" + strings.ReplaceAll(strs.UUID(), "-", "")[:12] } if count := seenIDs[id]; count > 0 { id = id + "-" + strings.ReplaceAll(strs.UUID(), "-", "")[:6] } seenIDs[id]++ visible := true if raw.Visible != nil { visible = *raw.Visible } children := []response.SupportNavigationMenuItemResponse(nil) if len(raw.Children) > 0 { if depth >= 2 { return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".children", "too_deep", "error.supportConfig.navigationTooDeep")} } if len(raw.Children) > 20 { return nil, 0, []response.ConfigFieldError{configFieldError(itemPath+".children", "too_many", "error.supportConfig.navigationChildrenTooMany")} } nextChildren, nextVisibleCount, fieldErrors := normalizeSupportNavigationItems(raw.Children, itemPath+".children", depth+1, seenIDs) if len(fieldErrors) > 0 { return nil, 0, fieldErrors } children = nextChildren if nextVisibleCount > 0 && visible { visibleCount += nextVisibleCount } } if visible { visibleCount++ } items = append(items, response.SupportNavigationMenuItemResponse{ ID: id, Title: title, URL: link, OpenInNewWindow: raw.OpenInNewWindow, Visible: visible, SortNo: (idx + 1) * 10, Children: children, }) } return items, visibleCount, nil } func isAllowedSupportNavigationURL(value string) bool { if strings.HasPrefix(value, "/") { return !strings.HasPrefix(value, "//") } parsed, err := url.Parse(value) if err != nil { return false } return parsed.Scheme == "http" || parsed.Scheme == "https" } func normalizeSupportNavigationMenuID(value string) string { value = strings.TrimSpace(strings.ToLower(value)) if value == "" { return "" } var builder strings.Builder for _, r := range value { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { builder.WriteRune(r) } } return strings.Trim(builder.String(), "-_") } func configFieldError(path, code, key string) response.ConfigFieldError { return response.ConfigFieldError{ Path: path, Code: code, MessageKey: key, Message: i18nx.Get(key), } } func localizeConfigFieldErrors(errors []response.ConfigFieldError, locale string) []response.ConfigFieldError { if len(errors) == 0 { return nil } ret := make([]response.ConfigFieldError, 0, len(errors)) for _, item := range errors { if item.MessageKey != "" { item.Message = i18nx.Getf(locale, item.MessageKey) } ret = append(ret, item) } return ret } func defaultSupportNavigationMenu() []response.SupportNavigationMenuItemResponse { return []response.SupportNavigationMenuItemResponse{ {ID: "home", Title: i18nx.Get("systemConfig.support.navigationMenu.default.home"), URL: "/support", SortNo: 10, Visible: true}, {ID: "docs", Title: i18nx.Get("systemConfig.support.navigationMenu.default.docs"), URL: "/support/docs", SortNo: 20, Visible: true}, {ID: "community", Title: i18nx.Get("systemConfig.support.navigationMenu.default.community"), URL: "/support/community/posts", SortNo: 30, Visible: true}, } }