360 lines
11 KiB
Go
360 lines
11 KiB
Go
// Package forms defines the forms a site declares in forms/*.yaml: how they
|
|
// render in a page and how a submission is checked. The build uses it to
|
|
// render and validate; the endpoint uses it to accept and deliver.
|
|
//
|
|
// A form accepts the fields it declares and nothing else. Each field has a
|
|
// type, a length limit and, for a select, the options it may take; single-line
|
|
// fields refuse line breaks, so nothing typed into one can add an email header.
|
|
package forms
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/mail"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"text/template"
|
|
"text/template/parse"
|
|
"unicode/utf8"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Form is one forms/<name>.yaml.
|
|
type Form struct {
|
|
Name string `yaml:"-" json:"name"`
|
|
Title string `yaml:"title" json:"title,omitempty"`
|
|
To string `yaml:"to" json:"to,omitempty"` // where submissions are emailed
|
|
Subject string `yaml:"subject" json:"subject,omitempty"` // a template over the fields: "Contact: {{ .topic }}"
|
|
ReplyTo string `yaml:"reply_to" json:"reply_to,omitempty"` // the email field to reply to, if any
|
|
Fields []Field `yaml:"fields" json:"fields,omitempty"`
|
|
Submit string `yaml:"submit" json:"submit,omitempty"` // the button's text
|
|
Success string `yaml:"success" json:"success,omitempty"` // where to send the visitor after a good submission
|
|
// Turnstile adds Cloudflare Turnstile with this site key; the endpoint
|
|
// checks it with HOTDOG_TURNSTILE_SECRET.
|
|
Turnstile string `yaml:"turnstile_sitekey" json:"turnstile_sitekey,omitempty"`
|
|
// Store appends each submission to a JSON-lines file as well as emailing it.
|
|
Store bool `yaml:"store" json:"store,omitempty"`
|
|
// Mailto is shown to visitors without JavaScript, who can't fetch the
|
|
// form's anti-spam token, as another way to get in touch.
|
|
Mailto string `yaml:"mailto" json:"mailto,omitempty"`
|
|
|
|
subject *template.Template
|
|
}
|
|
|
|
// Field is one input.
|
|
type Field struct {
|
|
Name string `yaml:"name" json:"name,omitempty"`
|
|
Type string `yaml:"type" json:"type,omitempty"` // text, email, url, tel, textarea, select, checkbox
|
|
Label string `yaml:"label" json:"label,omitempty"`
|
|
Help string `yaml:"help" json:"help,omitempty"`
|
|
Required bool `yaml:"required" json:"required,omitempty"`
|
|
Max int `yaml:"max" json:"max,omitempty"` // characters; defaults per type
|
|
Options []string `yaml:"options" json:"options,omitempty"`
|
|
// Placeholder is shown inside an empty field.
|
|
Placeholder string `yaml:"placeholder" json:"placeholder,omitempty"`
|
|
}
|
|
|
|
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,39}$`)
|
|
|
|
// Reserved names the endpoint uses for its own fields.
|
|
var reserved = map[string]bool{"_t": true, "_website": true, "cf-turnstile-response": true}
|
|
|
|
func (f *Field) maxLen() int {
|
|
if f.Max > 0 {
|
|
return f.Max
|
|
}
|
|
switch f.Type {
|
|
case "textarea":
|
|
return 5000
|
|
case "email", "url":
|
|
return 320
|
|
}
|
|
return 200
|
|
}
|
|
|
|
// Load reads every form in a site's forms/ folder. A site without the folder
|
|
// has no forms.
|
|
func Load(siteDir string) (map[string]*Form, error) {
|
|
dir := filepath.Join(siteDir, "forms")
|
|
entries, err := os.ReadDir(dir)
|
|
if os.IsNotExist(err) {
|
|
return map[string]*Form{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]*Form{}
|
|
for _, e := range entries {
|
|
if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(e.Name(), ".yaml")
|
|
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := Parse(name, raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("forms/%s: %w", e.Name(), err)
|
|
}
|
|
out[name] = f
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Parse reads one form's YAML, as Load does for each file.
|
|
func Parse(name string, raw []byte) (*Form, error) {
|
|
var f Form
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(&f); err != nil {
|
|
return nil, err
|
|
}
|
|
f.Name = name
|
|
if err := f.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
// ValidName reports whether name can be a form's name (its file and field).
|
|
func ValidName(name string) bool { return nameRe.MatchString(name) }
|
|
|
|
func (f *Form) check() error {
|
|
if !nameRe.MatchString(f.Name) {
|
|
return fmt.Errorf("form names are lower case letters, digits and _, starting with a letter")
|
|
}
|
|
if _, err := mail.ParseAddress(f.To); err != nil {
|
|
return fmt.Errorf("to %q is not an email address", f.To)
|
|
}
|
|
if len(f.Fields) == 0 {
|
|
return fmt.Errorf("no fields")
|
|
}
|
|
seen := map[string]bool{}
|
|
for i := range f.Fields {
|
|
fd := &f.Fields[i]
|
|
if !nameRe.MatchString(fd.Name) || reserved[fd.Name] {
|
|
return fmt.Errorf("field %q: names are lower case letters, digits and _", fd.Name)
|
|
}
|
|
if seen[fd.Name] {
|
|
return fmt.Errorf("field %q appears twice", fd.Name)
|
|
}
|
|
seen[fd.Name] = true
|
|
switch fd.Type {
|
|
case "":
|
|
fd.Type = "text"
|
|
case "text", "email", "url", "tel", "textarea", "checkbox":
|
|
case "select":
|
|
if len(fd.Options) == 0 {
|
|
return fmt.Errorf("field %q: a select needs options", fd.Name)
|
|
}
|
|
default:
|
|
return fmt.Errorf("field %q: type %q is not text, email, url, tel, textarea, select or checkbox", fd.Name, fd.Type)
|
|
}
|
|
if fd.Label == "" {
|
|
fd.Label = strings.ToUpper(fd.Name[:1]) + strings.ReplaceAll(fd.Name[1:], "_", " ")
|
|
}
|
|
}
|
|
if f.ReplyTo != "" {
|
|
ok := false
|
|
for _, fd := range f.Fields {
|
|
if fd.Name == f.ReplyTo && fd.Type == "email" {
|
|
ok = true
|
|
}
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("reply_to %q is not an email field of this form", f.ReplyTo)
|
|
}
|
|
}
|
|
if f.Subject == "" {
|
|
f.Subject = "Website form: " + f.Name
|
|
}
|
|
t, err := template.New("subject").Option("missingkey=zero").Parse(f.Subject)
|
|
if err != nil {
|
|
return fmt.Errorf("subject: %w", err)
|
|
}
|
|
if err := plainFields(t.Tree.Root); err != nil {
|
|
return fmt.Errorf("subject: %w", err)
|
|
}
|
|
f.subject = t
|
|
if f.Submit == "" {
|
|
f.Submit = "Send"
|
|
}
|
|
if f.Success != "" && !strings.HasPrefix(f.Success, "/") {
|
|
return fmt.Errorf("success must be a path on this site, such as /contact/thanks/")
|
|
}
|
|
if f.Mailto != "" {
|
|
if _, err := mail.ParseAddress(f.Mailto); err != nil {
|
|
return fmt.Errorf("mailto %q is not an email address", f.Mailto)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Submission is a checked set of values, in the form's field order.
|
|
type Submission struct {
|
|
Form *Form
|
|
Values map[string]string
|
|
}
|
|
|
|
// Problem is a field that didn't pass, in words a visitor can act on.
|
|
type Problem struct {
|
|
Field string `json:"field"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// Check validates submitted values against the form. Anything the form
|
|
// doesn't declare is refused outright rather than ignored: a field nobody
|
|
// asked for is either a mistake or a probe.
|
|
func (f *Form) Check(values map[string][]string) (*Submission, []Problem) {
|
|
var problems []Problem
|
|
known := map[string]bool{}
|
|
for _, fd := range f.Fields {
|
|
known[fd.Name] = true
|
|
}
|
|
var extra []string
|
|
for k := range values {
|
|
if !known[k] && !reserved[k] {
|
|
extra = append(extra, k)
|
|
}
|
|
}
|
|
if len(extra) > 0 {
|
|
sort.Strings(extra)
|
|
return nil, []Problem{{Field: "", Message: "This form doesn't take: " + strings.Join(extra, ", ")}}
|
|
}
|
|
sub := &Submission{Form: f, Values: map[string]string{}}
|
|
for _, fd := range f.Fields {
|
|
vs := values[fd.Name]
|
|
if len(vs) > 1 {
|
|
problems = append(problems, Problem{fd.Name, fd.Label + " was sent more than once."})
|
|
continue
|
|
}
|
|
v := ""
|
|
if len(vs) == 1 {
|
|
v = vs[0]
|
|
}
|
|
if !utf8.ValidString(v) || strings.ContainsRune(v, 0) {
|
|
problems = append(problems, Problem{fd.Name, fd.Label + " contains characters that can't be read."})
|
|
continue
|
|
}
|
|
if fd.Type != "textarea" {
|
|
v = strings.TrimSpace(v)
|
|
if strings.ContainsAny(v, "\r\n") {
|
|
problems = append(problems, Problem{fd.Name, fd.Label + " must be a single line."})
|
|
continue
|
|
}
|
|
} else {
|
|
v = strings.ReplaceAll(strings.TrimSpace(v), "\r\n", "\n")
|
|
}
|
|
if fd.Type == "checkbox" {
|
|
if v != "" && v != "on" && v != "yes" {
|
|
problems = append(problems, Problem{fd.Name, fd.Label + " has an unexpected value."})
|
|
continue
|
|
}
|
|
if v != "" {
|
|
v = "yes"
|
|
}
|
|
}
|
|
if v == "" {
|
|
if fd.Required {
|
|
problems = append(problems, Problem{fd.Name, fd.Label + " is required."})
|
|
}
|
|
sub.Values[fd.Name] = ""
|
|
continue
|
|
}
|
|
if n := utf8.RuneCountInString(v); n > fd.maxLen() {
|
|
problems = append(problems, Problem{fd.Name, fmt.Sprintf("%s is too long (%d characters, at most %d).", fd.Label, n, fd.maxLen())})
|
|
continue
|
|
}
|
|
switch fd.Type {
|
|
case "email":
|
|
a, err := mail.ParseAddress(v)
|
|
if err != nil || a.Name != "" || !strings.Contains(a.Address, ".") {
|
|
problems = append(problems, Problem{fd.Name, fd.Label + " doesn't look like an email address."})
|
|
continue
|
|
}
|
|
v = a.Address
|
|
case "url":
|
|
if !strings.HasPrefix(v, "https://") && !strings.HasPrefix(v, "http://") {
|
|
problems = append(problems, Problem{fd.Name, fd.Label + " should start with https://."})
|
|
continue
|
|
}
|
|
case "select":
|
|
ok := false
|
|
for _, o := range fd.Options {
|
|
if v == o {
|
|
ok = true
|
|
}
|
|
}
|
|
if !ok {
|
|
problems = append(problems, Problem{fd.Name, fd.Label + " has a value that isn't one of the choices."})
|
|
continue
|
|
}
|
|
}
|
|
sub.Values[fd.Name] = v
|
|
}
|
|
if len(problems) > 0 {
|
|
return nil, problems
|
|
}
|
|
return sub, nil
|
|
}
|
|
|
|
// plainFields allows a subject only text and {{ .field }}: no loops, no
|
|
// conditions, no functions. The endpoint fills it in for every submission,
|
|
// for every site it serves, so it mustn't be able to do work.
|
|
func plainFields(n parse.Node) error {
|
|
list, ok := n.(*parse.ListNode)
|
|
if !ok {
|
|
return fmt.Errorf("only text and {{ .field }} are allowed")
|
|
}
|
|
for _, c := range list.Nodes {
|
|
switch c := c.(type) {
|
|
case *parse.TextNode:
|
|
case *parse.ActionNode:
|
|
if len(c.Pipe.Decl) > 0 || len(c.Pipe.Cmds) != 1 || len(c.Pipe.Cmds[0].Args) != 1 {
|
|
return fmt.Errorf("only {{ .field }} is allowed, not %s", c)
|
|
}
|
|
if f, ok := c.Pipe.Cmds[0].Args[0].(*parse.FieldNode); !ok || len(f.Ident) != 1 {
|
|
return fmt.Errorf("only {{ .field }} is allowed, not %s", c)
|
|
}
|
|
default:
|
|
return fmt.Errorf("only text and {{ .field }} are allowed, not %s", c)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Subject renders the email subject from the values, on one line.
|
|
func (s *Submission) Subject() string {
|
|
var b strings.Builder
|
|
if err := s.Form.subject.Execute(&b, s.Values); err != nil {
|
|
return "Website form: " + s.Form.Name
|
|
}
|
|
out := strings.Join(strings.Fields(b.String()), " ")
|
|
if r := []rune(out); len(r) > 200 {
|
|
out = string(r[:200])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Text is the submission as a plain-text email body.
|
|
func (s *Submission) Text() string {
|
|
var b strings.Builder
|
|
for _, fd := range s.Form.Fields {
|
|
v := s.Values[fd.Name]
|
|
if v == "" {
|
|
v = "(left blank)"
|
|
}
|
|
if fd.Type == "textarea" {
|
|
fmt.Fprintf(&b, "%s:\n%s\n\n", fd.Label, v)
|
|
} else {
|
|
fmt.Fprintf(&b, "%s: %s\n", fd.Label, v)
|
|
}
|
|
}
|
|
return strings.TrimRight(b.String(), "\n") + "\n"
|
|
}
|