code.gitea.io/gitea@v1.22.3/modules/issue/template/template.go (about)

     1  // Copyright 2022 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package template
     5  
     6  import (
     7  	"fmt"
     8  	"net/url"
     9  	"regexp"
    10  	"strconv"
    11  	"strings"
    12  
    13  	"code.gitea.io/gitea/modules/container"
    14  	api "code.gitea.io/gitea/modules/structs"
    15  
    16  	"gitea.com/go-chi/binding"
    17  )
    18  
    19  // Validate checks whether an IssueTemplate is considered valid, and returns the first error
    20  func Validate(template *api.IssueTemplate) error {
    21  	if err := validateMetadata(template); err != nil {
    22  		return err
    23  	}
    24  	if template.Type() == api.IssueTemplateTypeYaml {
    25  		if err := validateYaml(template); err != nil {
    26  			return err
    27  		}
    28  	}
    29  	return nil
    30  }
    31  
    32  func validateMetadata(template *api.IssueTemplate) error {
    33  	if strings.TrimSpace(template.Name) == "" {
    34  		return fmt.Errorf("'name' is required")
    35  	}
    36  	if strings.TrimSpace(template.About) == "" {
    37  		return fmt.Errorf("'about' is required")
    38  	}
    39  	return nil
    40  }
    41  
    42  func validateYaml(template *api.IssueTemplate) error {
    43  	if len(template.Fields) == 0 {
    44  		return fmt.Errorf("'body' is required")
    45  	}
    46  	ids := make(container.Set[string])
    47  	for idx, field := range template.Fields {
    48  		if err := validateID(field, idx, ids); err != nil {
    49  			return err
    50  		}
    51  		if err := validateLabel(field, idx); err != nil {
    52  			return err
    53  		}
    54  
    55  		position := newErrorPosition(idx, field.Type)
    56  		switch field.Type {
    57  		case api.IssueFormFieldTypeMarkdown:
    58  			if err := validateStringItem(position, field.Attributes, true, "value"); err != nil {
    59  				return err
    60  			}
    61  		case api.IssueFormFieldTypeTextarea:
    62  			if err := validateStringItem(position, field.Attributes, false,
    63  				"description",
    64  				"placeholder",
    65  				"value",
    66  				"render",
    67  			); err != nil {
    68  				return err
    69  			}
    70  		case api.IssueFormFieldTypeInput:
    71  			if err := validateStringItem(position, field.Attributes, false,
    72  				"description",
    73  				"placeholder",
    74  				"value",
    75  			); err != nil {
    76  				return err
    77  			}
    78  			if err := validateBoolItem(position, field.Validations, "is_number"); err != nil {
    79  				return err
    80  			}
    81  			if err := validateStringItem(position, field.Validations, false, "regex"); err != nil {
    82  				return err
    83  			}
    84  		case api.IssueFormFieldTypeDropdown:
    85  			if err := validateStringItem(position, field.Attributes, false, "description"); err != nil {
    86  				return err
    87  			}
    88  			if err := validateBoolItem(position, field.Attributes, "multiple"); err != nil {
    89  				return err
    90  			}
    91  			if err := validateOptions(field, idx); err != nil {
    92  				return err
    93  			}
    94  			if err := validateDropdownDefault(position, field.Attributes); err != nil {
    95  				return err
    96  			}
    97  		case api.IssueFormFieldTypeCheckboxes:
    98  			if err := validateStringItem(position, field.Attributes, false, "description"); err != nil {
    99  				return err
   100  			}
   101  			if err := validateOptions(field, idx); err != nil {
   102  				return err
   103  			}
   104  		default:
   105  			return position.Errorf("unknown type")
   106  		}
   107  
   108  		if err := validateRequired(field, idx); err != nil {
   109  			return err
   110  		}
   111  	}
   112  	return nil
   113  }
   114  
   115  func validateLabel(field *api.IssueFormField, idx int) error {
   116  	if field.Type == api.IssueFormFieldTypeMarkdown {
   117  		// The label is not required for a markdown field
   118  		return nil
   119  	}
   120  	return validateStringItem(newErrorPosition(idx, field.Type), field.Attributes, true, "label")
   121  }
   122  
   123  func validateRequired(field *api.IssueFormField, idx int) error {
   124  	if field.Type == api.IssueFormFieldTypeMarkdown || field.Type == api.IssueFormFieldTypeCheckboxes {
   125  		// The label is not required for a markdown or checkboxes field
   126  		return nil
   127  	}
   128  	if err := validateBoolItem(newErrorPosition(idx, field.Type), field.Validations, "required"); err != nil {
   129  		return err
   130  	}
   131  	if required, _ := field.Validations["required"].(bool); required && !field.VisibleOnForm() {
   132  		return newErrorPosition(idx, field.Type).Errorf("can not require a hidden field")
   133  	}
   134  	return nil
   135  }
   136  
   137  func validateID(field *api.IssueFormField, idx int, ids container.Set[string]) error {
   138  	if field.Type == api.IssueFormFieldTypeMarkdown {
   139  		// The ID is not required for a markdown field
   140  		return nil
   141  	}
   142  
   143  	position := newErrorPosition(idx, field.Type)
   144  	if field.ID == "" {
   145  		// If the ID is empty in yaml, template.Unmarshal will auto autofill it, so it cannot be empty
   146  		return position.Errorf("'id' is required")
   147  	}
   148  	if binding.AlphaDashPattern.MatchString(field.ID) {
   149  		return position.Errorf("'id' should contain only alphanumeric, '-' and '_'")
   150  	}
   151  	if !ids.Add(field.ID) {
   152  		return position.Errorf("'id' should be unique")
   153  	}
   154  	return nil
   155  }
   156  
   157  func validateOptions(field *api.IssueFormField, idx int) error {
   158  	if field.Type != api.IssueFormFieldTypeDropdown && field.Type != api.IssueFormFieldTypeCheckboxes {
   159  		return nil
   160  	}
   161  	position := newErrorPosition(idx, field.Type)
   162  
   163  	options, ok := field.Attributes["options"].([]any)
   164  	if !ok || len(options) == 0 {
   165  		return position.Errorf("'options' is required and should be a array")
   166  	}
   167  
   168  	for optIdx, option := range options {
   169  		position := newErrorPosition(idx, field.Type, optIdx)
   170  		switch field.Type {
   171  		case api.IssueFormFieldTypeDropdown:
   172  			if _, ok := option.(string); !ok {
   173  				return position.Errorf("should be a string")
   174  			}
   175  		case api.IssueFormFieldTypeCheckboxes:
   176  			opt, ok := option.(map[string]any)
   177  			if !ok {
   178  				return position.Errorf("should be a dictionary")
   179  			}
   180  			if label, ok := opt["label"].(string); !ok || label == "" {
   181  				return position.Errorf("'label' is required and should be a string")
   182  			}
   183  
   184  			if visibility, ok := opt["visible"]; ok {
   185  				visibilityList, ok := visibility.([]any)
   186  				if !ok {
   187  					return position.Errorf("'visible' should be list")
   188  				}
   189  				for _, visibleType := range visibilityList {
   190  					visibleType, ok := visibleType.(string)
   191  					if !ok || !(visibleType == "form" || visibleType == "content") {
   192  						return position.Errorf("'visible' list can only contain strings of 'form' and 'content'")
   193  					}
   194  				}
   195  			}
   196  
   197  			if required, ok := opt["required"]; ok {
   198  				if _, ok := required.(bool); !ok {
   199  					return position.Errorf("'required' should be a bool")
   200  				}
   201  
   202  				// validate if hidden field is required
   203  				if visibility, ok := opt["visible"]; ok {
   204  					visibilityList, _ := visibility.([]any)
   205  					isVisible := false
   206  					for _, v := range visibilityList {
   207  						if vv, _ := v.(string); vv == "form" {
   208  							isVisible = true
   209  							break
   210  						}
   211  					}
   212  					if !isVisible {
   213  						return position.Errorf("can not require a hidden checkbox")
   214  					}
   215  				}
   216  			}
   217  		}
   218  	}
   219  	return nil
   220  }
   221  
   222  func validateStringItem(position errorPosition, m map[string]any, required bool, names ...string) error {
   223  	for _, name := range names {
   224  		v, ok := m[name]
   225  		if !ok {
   226  			if required {
   227  				return position.Errorf("'%s' is required", name)
   228  			}
   229  			return nil
   230  		}
   231  		attr, ok := v.(string)
   232  		if !ok {
   233  			return position.Errorf("'%s' should be a string", name)
   234  		}
   235  		if strings.TrimSpace(attr) == "" && required {
   236  			return position.Errorf("'%s' is required", name)
   237  		}
   238  	}
   239  	return nil
   240  }
   241  
   242  func validateBoolItem(position errorPosition, m map[string]any, names ...string) error {
   243  	for _, name := range names {
   244  		v, ok := m[name]
   245  		if !ok {
   246  			return nil
   247  		}
   248  		if _, ok := v.(bool); !ok {
   249  			return position.Errorf("'%s' should be a bool", name)
   250  		}
   251  	}
   252  	return nil
   253  }
   254  
   255  func validateDropdownDefault(position errorPosition, attributes map[string]any) error {
   256  	v, ok := attributes["default"]
   257  	if !ok {
   258  		return nil
   259  	}
   260  	defaultValue, ok := v.(int)
   261  	if !ok {
   262  		return position.Errorf("'default' should be an int")
   263  	}
   264  
   265  	options, ok := attributes["options"].([]any)
   266  	if !ok {
   267  		// should not happen
   268  		return position.Errorf("'options' is required and should be a array")
   269  	}
   270  	if defaultValue < 0 || defaultValue >= len(options) {
   271  		return position.Errorf("the value of 'default' is out of range")
   272  	}
   273  
   274  	return nil
   275  }
   276  
   277  type errorPosition string
   278  
   279  func (p errorPosition) Errorf(format string, a ...any) error {
   280  	return fmt.Errorf(string(p)+": "+format, a...)
   281  }
   282  
   283  func newErrorPosition(fieldIdx int, fieldType api.IssueFormFieldType, optionIndex ...int) errorPosition {
   284  	ret := fmt.Sprintf("body[%d](%s)", fieldIdx, fieldType)
   285  	if len(optionIndex) > 0 {
   286  		ret += fmt.Sprintf(", option[%d]", optionIndex[0])
   287  	}
   288  	return errorPosition(ret)
   289  }
   290  
   291  // RenderToMarkdown renders template to markdown with specified values
   292  func RenderToMarkdown(template *api.IssueTemplate, values url.Values) string {
   293  	builder := &strings.Builder{}
   294  
   295  	for _, field := range template.Fields {
   296  		f := &valuedField{
   297  			IssueFormField: field,
   298  			Values:         values,
   299  		}
   300  		if f.ID == "" || !f.VisibleInContent() {
   301  			continue
   302  		}
   303  		f.WriteTo(builder)
   304  	}
   305  
   306  	return builder.String()
   307  }
   308  
   309  type valuedField struct {
   310  	*api.IssueFormField
   311  	url.Values
   312  }
   313  
   314  func (f *valuedField) WriteTo(builder *strings.Builder) {
   315  	// write label
   316  	if !f.HideLabel() {
   317  		_, _ = fmt.Fprintf(builder, "### %s\n\n", f.Label())
   318  	}
   319  
   320  	blankPlaceholder := "_No response_\n"
   321  
   322  	// write body
   323  	switch f.Type {
   324  	case api.IssueFormFieldTypeCheckboxes:
   325  		for _, option := range f.Options() {
   326  			if !option.VisibleInContent() {
   327  				continue
   328  			}
   329  			checked := " "
   330  			if option.IsChecked() {
   331  				checked = "x"
   332  			}
   333  			_, _ = fmt.Fprintf(builder, "- [%s] %s\n", checked, option.Label())
   334  		}
   335  	case api.IssueFormFieldTypeDropdown:
   336  		var checkeds []string
   337  		for _, option := range f.Options() {
   338  			if option.IsChecked() {
   339  				checkeds = append(checkeds, option.Label())
   340  			}
   341  		}
   342  		if len(checkeds) > 0 {
   343  			_, _ = fmt.Fprintf(builder, "%s\n", strings.Join(checkeds, ", "))
   344  		} else {
   345  			_, _ = fmt.Fprint(builder, blankPlaceholder)
   346  		}
   347  	case api.IssueFormFieldTypeInput:
   348  		if value := f.Value(); value == "" {
   349  			_, _ = fmt.Fprint(builder, blankPlaceholder)
   350  		} else {
   351  			_, _ = fmt.Fprintf(builder, "%s\n", value)
   352  		}
   353  	case api.IssueFormFieldTypeTextarea:
   354  		if value := f.Value(); value == "" {
   355  			_, _ = fmt.Fprint(builder, blankPlaceholder)
   356  		} else if render := f.Render(); render != "" {
   357  			quotes := minQuotes(value)
   358  			_, _ = fmt.Fprintf(builder, "%s%s\n%s\n%s\n", quotes, f.Render(), value, quotes)
   359  		} else {
   360  			_, _ = fmt.Fprintf(builder, "%s\n", value)
   361  		}
   362  	case api.IssueFormFieldTypeMarkdown:
   363  		if value, ok := f.Attributes["value"].(string); ok {
   364  			_, _ = fmt.Fprintf(builder, "%s\n", value)
   365  		}
   366  	}
   367  	_, _ = fmt.Fprintln(builder)
   368  }
   369  
   370  func (f *valuedField) Label() string {
   371  	if label, ok := f.Attributes["label"].(string); ok {
   372  		return label
   373  	}
   374  	return ""
   375  }
   376  
   377  func (f *valuedField) HideLabel() bool {
   378  	if f.Type == api.IssueFormFieldTypeMarkdown {
   379  		return true
   380  	}
   381  	if label, ok := f.Attributes["hide_label"].(bool); ok {
   382  		return label
   383  	}
   384  	return false
   385  }
   386  
   387  func (f *valuedField) Render() string {
   388  	if render, ok := f.Attributes["render"].(string); ok {
   389  		return render
   390  	}
   391  	return ""
   392  }
   393  
   394  func (f *valuedField) Value() string {
   395  	return strings.TrimSpace(f.Get(fmt.Sprintf("form-field-" + f.ID)))
   396  }
   397  
   398  func (f *valuedField) Options() []*valuedOption {
   399  	if options, ok := f.Attributes["options"].([]any); ok {
   400  		ret := make([]*valuedOption, 0, len(options))
   401  		for i, option := range options {
   402  			ret = append(ret, &valuedOption{
   403  				index: i,
   404  				data:  option,
   405  				field: f,
   406  			})
   407  		}
   408  		return ret
   409  	}
   410  	return nil
   411  }
   412  
   413  type valuedOption struct {
   414  	index int
   415  	data  any
   416  	field *valuedField
   417  }
   418  
   419  func (o *valuedOption) Label() string {
   420  	switch o.field.Type {
   421  	case api.IssueFormFieldTypeDropdown:
   422  		if label, ok := o.data.(string); ok {
   423  			return label
   424  		}
   425  	case api.IssueFormFieldTypeCheckboxes:
   426  		if vs, ok := o.data.(map[string]any); ok {
   427  			if v, ok := vs["label"].(string); ok {
   428  				return v
   429  			}
   430  		}
   431  	}
   432  	return ""
   433  }
   434  
   435  func (o *valuedOption) IsChecked() bool {
   436  	switch o.field.Type {
   437  	case api.IssueFormFieldTypeDropdown:
   438  		checks := strings.Split(o.field.Get(fmt.Sprintf("form-field-%s", o.field.ID)), ",")
   439  		idx := strconv.Itoa(o.index)
   440  		for _, v := range checks {
   441  			if v == idx {
   442  				return true
   443  			}
   444  		}
   445  		return false
   446  	case api.IssueFormFieldTypeCheckboxes:
   447  		return o.field.Get(fmt.Sprintf("form-field-%s-%d", o.field.ID, o.index)) == "on"
   448  	}
   449  	return false
   450  }
   451  
   452  func (o *valuedOption) VisibleInContent() bool {
   453  	if o.field.Type == api.IssueFormFieldTypeCheckboxes {
   454  		if vs, ok := o.data.(map[string]any); ok {
   455  			if vl, ok := vs["visible"].([]any); ok {
   456  				for _, v := range vl {
   457  					if vv, _ := v.(string); vv == "content" {
   458  						return true
   459  					}
   460  				}
   461  				return false
   462  			}
   463  		}
   464  	}
   465  	return true
   466  }
   467  
   468  var minQuotesRegex = regexp.MustCompilePOSIX("^`{3,}")
   469  
   470  // minQuotes return 3 or more back-quotes.
   471  // If n back-quotes exists, use n+1 back-quotes to quote.
   472  func minQuotes(value string) string {
   473  	ret := "```"
   474  	for _, v := range minQuotesRegex.FindAllString(value, -1) {
   475  		if len(v) >= len(ret) {
   476  			ret = v + "`"
   477  		}
   478  	}
   479  	return ret
   480  }