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 }