// Copyright 2022 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package template import ( "fmt" "net/url" "regexp" "strconv" "strings" "code.gitea.io/gitea/modules/container" api "code.gitea.io/gitea/modules/structs" "code.forgejo.org/go-chi/binding" ) // Validate checks whether an IssueTemplate is considered valid, and returns the first error func Validate(template *api.IssueTemplate) error { if err := validateMetadata(template); err != nil { return err } if template.Type() == api.IssueTemplateTypeYaml { if err := validateYaml(template); err != nil { return err } } return nil } func validateMetadata(template *api.IssueTemplate) error { if strings.TrimSpace(template.Name) == "" { return fmt.Errorf("'name' is required") } if strings.TrimSpace(template.About) == "" { return fmt.Errorf("'about' is required") } return nil } func validateYaml(template *api.IssueTemplate) error { if len(template.Fields) == 0 { return fmt.Errorf("'body' is required") } ids := make(container.Set[string]) for idx, field := range template.Fields { if err := validateID(field, idx, ids); err != nil { return err } if err := validateLabel(field, idx); err != nil { return err } position := newErrorPosition(idx, field.Type) switch field.Type { case api.IssueFormFieldTypeMarkdown: if err := validateStringItem(position, field.Attributes, true, "value"); err != nil { return err } case api.IssueFormFieldTypeTextarea: if err := validateStringItem(position, field.Attributes, false, "description", "placeholder", "value", "render", ); err != nil { return err } case api.IssueFormFieldTypeInput: if err := validateStringItem(position, field.Attributes, false, "description", "placeholder", "value", ); err != nil { return err } if err := validateBoolItem(position, field.Validations, "is_number"); err != nil { return err } if err := validateStringItem(position, field.Validations, false, "regex"); err != nil { return err } case api.IssueFormFieldTypeDropdown: if err := validateStringItem(position, field.Attributes, false, "description"); err != nil { return err } if err := validateBoolItem(position, field.Attributes, "multiple"); err != nil { return err } if err := validateBoolItem(position, field.Attributes, "list"); err != nil { return err } if err := validateOptions(field, idx); err != nil { return err } if err := validateDropdownDefault(position, field.Attributes); err != nil { return err } case api.IssueFormFieldTypeCheckboxes: if err := validateStringItem(position, field.Attributes, false, "description"); err != nil { return err } if err := validateOptions(field, idx); err != nil { return err } default: return position.Errorf("unknown type") } if err := validateRequired(field, idx); err != nil { return err } } return nil } func validateLabel(field *api.IssueFormField, idx int) error { if field.Type == api.IssueFormFieldTypeMarkdown { // The label is not required for a markdown field return nil } return validateStringItem(newErrorPosition(idx, field.Type), field.Attributes, true, "label") } func validateRequired(field *api.IssueFormField, idx int) error { if field.Type == api.IssueFormFieldTypeMarkdown || field.Type == api.IssueFormFieldTypeCheckboxes { // The label is not required for a markdown or checkboxes field return nil } if err := validateBoolItem(newErrorPosition(idx, field.Type), field.Validations, "required"); err != nil { return err } if required, _ := field.Validations["required"].(bool); required && !field.VisibleOnForm() { return newErrorPosition(idx, field.Type).Errorf("can not require a hidden field") } return nil } func validateID(field *api.IssueFormField, idx int, ids container.Set[string]) error { if field.Type == api.IssueFormFieldTypeMarkdown { // The ID is not required for a markdown field return nil } position := newErrorPosition(idx, field.Type) if field.ID == "" { // If the ID is empty in yaml, template.Unmarshal will auto autofill it, so it cannot be empty return position.Errorf("'id' is required") } if binding.AlphaDashPattern.MatchString(field.ID) { return position.Errorf("'id' should contain only alphanumeric, '-' and '_'") } if !ids.Add(field.ID) { return position.Errorf("'id' should be unique") } return nil } func validateOptions(field *api.IssueFormField, idx int) error { if field.Type != api.IssueFormFieldTypeDropdown && field.Type != api.IssueFormFieldTypeCheckboxes { return nil } position := newErrorPosition(idx, field.Type) options, ok := field.Attributes["options"].([]any) if !ok || len(options) == 0 { return position.Errorf("'options' is required and should be a array") } for optIdx, option := range options { position := newErrorPosition(idx, field.Type, optIdx) switch field.Type { case api.IssueFormFieldTypeDropdown: if _, ok := option.(string); !ok { return position.Errorf("should be a string") } case api.IssueFormFieldTypeCheckboxes: opt, ok := option.(map[string]any) if !ok { return position.Errorf("should be a dictionary") } if label, ok := opt["label"].(string); !ok || label == "" { return position.Errorf("'label' is required and should be a string") } if visibility, ok := opt["visible"]; ok { visibilityList, ok := visibility.([]any) if !ok { return position.Errorf("'visible' should be list") } for _, visibleType := range visibilityList { visibleType, ok := visibleType.(string) if !ok || !(visibleType == "form" || visibleType == "content") { return position.Errorf("'visible' list can only contain strings of 'form' and 'content'") } } } if required, ok := opt["required"]; ok { if _, ok := required.(bool); !ok { return position.Errorf("'required' should be a bool") } // validate if hidden field is required if visibility, ok := opt["visible"]; ok { visibilityList, _ := visibility.([]any) isVisible := false for _, v := range visibilityList { if vv, _ := v.(string); vv == "form" { isVisible = true break } } if !isVisible { return position.Errorf("can not require a hidden checkbox") } } } } } return nil } func validateStringItem(position errorPosition, m map[string]any, required bool, names ...string) error { for _, name := range names { v, ok := m[name] if !ok { if required { return position.Errorf("'%s' is required", name) } return nil } attr, ok := v.(string) if !ok { return position.Errorf("'%s' should be a string", name) } if strings.TrimSpace(attr) == "" && required { return position.Errorf("'%s' is required", name) } } return nil } func validateBoolItem(position errorPosition, m map[string]any, names ...string) error { for _, name := range names { v, ok := m[name] if !ok { return nil } if _, ok := v.(bool); !ok { return position.Errorf("'%s' should be a bool", name) } } return nil } func validateDropdownDefault(position errorPosition, attributes map[string]any) error { v, ok := attributes["default"] if !ok { return nil } defaultValue, ok := v.(int) if !ok { return position.Errorf("'default' should be an int") } options, ok := attributes["options"].([]any) if !ok { // should not happen return position.Errorf("'options' is required and should be a array") } if defaultValue < 0 || defaultValue >= len(options) { return position.Errorf("the value of 'default' is out of range") } return nil } type errorPosition string func (p errorPosition) Errorf(format string, a ...any) error { return fmt.Errorf(string(p)+": "+format, a...) } func newErrorPosition(fieldIdx int, fieldType api.IssueFormFieldType, optionIndex ...int) errorPosition { ret := fmt.Sprintf("body[%d](%s)", fieldIdx, fieldType) if len(optionIndex) > 0 { ret += fmt.Sprintf(", option[%d]", optionIndex[0]) } return errorPosition(ret) } // RenderToMarkdown renders template to markdown with specified values func RenderToMarkdown(template *api.IssueTemplate, values url.Values) string { builder := &strings.Builder{} for _, field := range template.Fields { f := &valuedField{ IssueFormField: field, Values: values, } if f.ID == "" || !f.VisibleInContent() { continue } f.WriteTo(builder) } return builder.String() } type valuedField struct { *api.IssueFormField url.Values } func (f *valuedField) WriteTo(builder *strings.Builder) { // write label if !f.HideLabel() { _, _ = fmt.Fprintf(builder, "### %s\n\n", f.Label()) } blankPlaceholder := "_No response_\n" // write body switch f.Type { case api.IssueFormFieldTypeCheckboxes: for _, option := range f.Options() { if !option.VisibleInContent() { continue } checked := " " if option.IsChecked() { checked = "x" } _, _ = fmt.Fprintf(builder, "- [%s] %s\n", checked, option.Label()) } case api.IssueFormFieldTypeDropdown: var checkeds []string for _, option := range f.Options() { if option.IsChecked() { checkeds = append(checkeds, option.Label()) } } if len(checkeds) > 0 { if list, ok := f.Attributes["list"].(bool); ok && list { for _, check := range checkeds { _, _ = fmt.Fprintf(builder, "- %s\n", check) } } else { _, _ = fmt.Fprintf(builder, "%s\n", strings.Join(checkeds, ", ")) } } else { _, _ = fmt.Fprint(builder, blankPlaceholder) } case api.IssueFormFieldTypeInput: if value := f.Value(); value == "" { _, _ = fmt.Fprint(builder, blankPlaceholder) } else { _, _ = fmt.Fprintf(builder, "%s\n", value) } case api.IssueFormFieldTypeTextarea: if value := f.Value(); value == "" { _, _ = fmt.Fprint(builder, blankPlaceholder) } else if render := f.Render(); render != "" { quotes := minQuotes(value) _, _ = fmt.Fprintf(builder, "%s%s\n%s\n%s\n", quotes, f.Render(), value, quotes) } else { _, _ = fmt.Fprintf(builder, "%s\n", value) } case api.IssueFormFieldTypeMarkdown: if value, ok := f.Attributes["value"].(string); ok { _, _ = fmt.Fprintf(builder, "%s\n", value) } } _, _ = fmt.Fprintln(builder) } func (f *valuedField) Label() string { if label, ok := f.Attributes["label"].(string); ok { return label } return "" } func (f *valuedField) HideLabel() bool { if f.Type == api.IssueFormFieldTypeMarkdown { return true } if label, ok := f.Attributes["hide_label"].(bool); ok { return label } return false } func (f *valuedField) Render() string { if render, ok := f.Attributes["render"].(string); ok { return render } return "" } func (f *valuedField) Value() string { return strings.TrimSpace(f.Get("form-field-" + f.ID)) } func (f *valuedField) Options() []*valuedOption { if options, ok := f.Attributes["options"].([]any); ok { ret := make([]*valuedOption, 0, len(options)) for i, option := range options { ret = append(ret, &valuedOption{ index: i, data: option, field: f, }) } return ret } return nil } type valuedOption struct { index int data any field *valuedField } func (o *valuedOption) Label() string { switch o.field.Type { case api.IssueFormFieldTypeDropdown: if label, ok := o.data.(string); ok { return label } case api.IssueFormFieldTypeCheckboxes: if vs, ok := o.data.(map[string]any); ok { if v, ok := vs["label"].(string); ok { return v } } } return "" } func (o *valuedOption) IsChecked() bool { switch o.field.Type { case api.IssueFormFieldTypeDropdown: checks := strings.Split(o.field.Get(fmt.Sprintf("form-field-%s", o.field.ID)), ",") idx := strconv.Itoa(o.index) for _, v := range checks { if v == idx { return true } } return false case api.IssueFormFieldTypeCheckboxes: return o.field.Get(fmt.Sprintf("form-field-%s-%d", o.field.ID, o.index)) == "on" } return false } func (o *valuedOption) VisibleInContent() bool { if o.field.Type == api.IssueFormFieldTypeCheckboxes { if vs, ok := o.data.(map[string]any); ok { if vl, ok := vs["visible"].([]any); ok { for _, v := range vl { if vv, _ := v.(string); vv == "content" { return true } } return false } } } return true } var minQuotesRegex = regexp.MustCompilePOSIX("^`{3,}") // minQuotes return 3 or more back-quotes. // If n back-quotes exists, use n+1 back-quotes to quote. func minQuotes(value string) string { ret := "```" for _, v := range minQuotesRegex.FindAllString(value, -1) { if len(v) >= len(ret) { ret = v + "`" } } return ret }