Forms are YAML in forms/, rendered by {{ form }} with a small script and stylesheet the build publishes. cl-cms endpoint serves /_cl/ for any number of sites by host: it checks Origin, a per-IP rate limit, text-only bodies, a trap field, a signed 3 s to 24 h token and optional Turnstile, then the declared fields (types, lengths, options, no line breaks in single-line fields), and delivers by SMTP over TLS, optionally appending to a 0600 JSONL file. It also answers search from a collection's search.json, so a site can search in the browser or from the endpoint. cl-cms serve can pass /_cl/ to an endpoint, and -reload=false leaves out the live-reload script for headless browsers.
68 lines
1.7 KiB
Go
68 lines
1.7 KiB
Go
package endpoint
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// A form token says "this endpoint handed out this form, for this site, at
|
|
// this time". It is signed, so it can't be forged, and checked for age:
|
|
// younger than minAge is a script filling the form instantly, older than
|
|
// maxAge is a replay of something harvested long ago. Nothing is stored, so
|
|
// any number of endpoints behind a load balancer can check each other's
|
|
// tokens as long as they share the secret.
|
|
const (
|
|
minAge = 3 * time.Second
|
|
maxAge = 24 * time.Hour
|
|
)
|
|
|
|
type tokens struct{ secret []byte }
|
|
|
|
func (t tokens) sign(host, form string, ts int64) string {
|
|
m := hmac.New(sha256.New, t.secret)
|
|
m.Write([]byte("v1|" + host + "|" + form + "|" + strconv.FormatInt(ts, 10)))
|
|
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
|
}
|
|
|
|
func (t tokens) issue(host, form string, now time.Time) string {
|
|
ts := now.Unix()
|
|
return "v1." + strconv.FormatInt(ts, 10) + "." + t.sign(host, form, ts)
|
|
}
|
|
|
|
var (
|
|
errTokenMissing = errors.New("missing")
|
|
errTokenBad = errors.New("not valid")
|
|
errTokenYoung = errors.New("sent too quickly")
|
|
errTokenOld = errors.New("expired")
|
|
)
|
|
|
|
func (t tokens) check(tok, host, form string, now time.Time) error {
|
|
if tok == "" {
|
|
return errTokenMissing
|
|
}
|
|
parts := strings.Split(tok, ".")
|
|
if len(parts) != 3 || parts[0] != "v1" {
|
|
return errTokenBad
|
|
}
|
|
ts, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return errTokenBad
|
|
}
|
|
if !hmac.Equal([]byte(parts[2]), []byte(t.sign(host, form, ts))) {
|
|
return errTokenBad
|
|
}
|
|
age := now.Sub(time.Unix(ts, 0))
|
|
switch {
|
|
case age < minAge:
|
|
return errTokenYoung
|
|
case age > maxAge:
|
|
return errTokenOld
|
|
}
|
|
return nil
|
|
}
|