Files
hotdog-cms/internal/endpoint/token.go
T
jcoffey-dev 85bbf5f828 Forms, the endpoint, and search from it
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.
2026-10-10 14:10:17 -07:00

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
}