mirror of
https://codeberg.org/forgejo/forgejo
synced 2024-11-25 11:16:11 +01:00
310376525b
- Use the forked [binding](https://code.forgejo.org/go-chi/binding) library. This library has two benefits, it removes the usage of `github.com/goccy/go-json` (has no benefit as the minimo library is also using it). It adds the `TrimSpace` feature, which will during the binding part trim the spaces around the value it got from the form, this is done before validation.
490 lines
12 KiB
Go
490 lines
12 KiB
Go
// 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
|
|
}
|