489 lines · 12.4 KB · Go
RawDownload
// Copyright 2022 The Gitea Authors. All rights reserved.// SPDX-License-Identifier: MIT package template import (	"fmt"	"net/url"	"regexp"	"strconv"	"strings" 	"forgejo.org/modules/container"	api "forgejo.org/modules/structs" 	"code.forgejo.org/go-chi/binding") // Validate checks whether an IssueTemplate is considered valid, and returns the first errorfunc 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 valuesfunc 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}