Files

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"
}