Files

297 lines
9.1 KiB
Go

package endpoint
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log"
"mime"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/about"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forms"
)
// Server answers /_hotdog/... for every configured site.
type Server struct {
cfg *Config
sites map[string]*siteRuntime
tokens tokens
limit *limiter
// searchLimit: searches are cheap but not free; 120 a minute per
// connection is far beyond anyone typing.
searchLimit *limiter
mail *mailer
store store
trusted []*net.IPNet
turnstile string // HOTDOG_TURNSTILE_SECRET
search *searcher
now func() time.Time
log *log.Logger
}
// New builds a server from endpoint.yaml. The form secret must be at least 32
// characters: every endpoint behind one load balancer needs the same one.
func New(configFile string, logw io.Writer) (*Server, error) {
cfg, sites, err := LoadConfig(configFile)
if err != nil {
return nil, err
}
secret := os.Getenv("HOTDOG_FORM_SECRET")
if len(secret) < 32 {
return nil, errors.New("HOTDOG_FORM_SECRET must be set, at least 32 characters (openssl rand -hex 32)")
}
s := &Server{
cfg: cfg, sites: sites, tokens: tokens{secret: []byte(secret)},
limit: newLimiter(cfg.RateLimit.Count, cfg.RateLimit.Window),
searchLimit: newLimiter(120, time.Minute),
mail: newMailer(cfg.SMTP), turnstile: os.Getenv("HOTDOG_TURNSTILE_SECRET"),
now: time.Now, log: log.New(logw, "", log.LstdFlags),
}
for _, p := range cfg.TrustedProxies {
_, n, _ := net.ParseCIDR(p)
s.trusted = append(s.trusted, n)
}
s.search = newSearcher()
return s, nil
}
// Listen is the configured address.
func (s *Server) Listen() string { return s.cfg.Listen }
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cache-Control", "no-store")
if r.URL.Path == "/_hotdog/health" {
fmt.Fprintln(w, "ok")
return
}
if r.URL.Path == "/_hotdog/source" {
http.Redirect(w, r, about.Get(s.cfg.Source, nil).Source, http.StatusFound)
return
}
host := strings.ToLower(r.Host)
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
rt := s.sites[host]
if rt == nil {
http.Error(w, "unknown site", http.StatusNotFound)
return
}
path := strings.TrimPrefix(r.URL.Path, "/_hotdog/")
switch {
case strings.HasPrefix(path, "forms/"):
rest := strings.TrimPrefix(path, "forms/")
name, tail, _ := strings.Cut(rest, "/")
f := rt.forms[name]
if f == nil {
http.Error(w, "no such form", http.StatusNotFound)
return
}
switch {
case tail == "token" && r.Method == http.MethodGet:
writeJSON(w, http.StatusOK, map[string]string{"token": s.tokens.issue(host, name, s.now())})
case tail == "" && r.Method == http.MethodPost:
s.submit(w, r, rt, host, f)
default:
http.Error(w, "not here", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "search/") && r.Method == http.MethodGet:
s.handleSearch(w, r, rt, strings.TrimSuffix(strings.TrimPrefix(path, "search/"), "/"))
default:
http.NotFound(w, r)
}
}
// clientIP is the visitor's address: the peer's, unless the peer is a
// trusted proxy, in which case the nearest address in X-Forwarded-For that
// isn't one of ours.
func (s *Server) clientIP(r *http.Request) string {
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peer = r.RemoteAddr
}
if !s.isTrusted(peer) {
return peer
}
hops := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(hops) - 1; i >= 0; i-- {
h := strings.TrimSpace(hops[i])
if h == "" {
continue
}
if net.ParseIP(h) == nil {
return peer
}
if !s.isTrusted(h) {
return h
}
}
return peer
}
// limitKey is what the rate limit counts by: an IPv4 address, or an IPv6
// address's /64, since one connection is usually given a whole /64 and could
// otherwise use a new address for every message.
func limitKey(ip string) string {
p := net.ParseIP(ip)
if p == nil || p.To4() != nil {
return ip
}
return p.Mask(net.CIDRMask(64, 128)).String() + "/64"
}
func (s *Server) isTrusted(ip string) bool {
p := net.ParseIP(ip)
if p == nil {
return false
}
for _, n := range s.trusted {
if n.Contains(p) {
return true
}
}
return false
}
// sameSite reports whether the request came from a page on the site itself.
// Browsers send Origin with every form POST; a post from another site, or
// from a script that doesn't bother, is refused.
func sameSite(r *http.Request, rt *siteRuntime) bool {
o := r.Header.Get("Origin")
if o == "" || o == "null" {
return false
}
u, err := url.Parse(o)
if err != nil {
return false
}
h := strings.ToLower(u.Hostname())
for _, ok := range rt.hosts {
if h == strings.ToLower(ok) {
return true
}
}
return false
}
type reply struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
Problems []forms.Problem `json:"problems,omitempty"`
Retoken bool `json:"retoken,omitempty"`
}
func (s *Server) submit(w http.ResponseWriter, r *http.Request, rt *siteRuntime, host string, f *forms.Form) {
wantsJSON := strings.Contains(r.Header.Get("Accept"), "application/json")
answer := func(code int, rp reply) {
if wantsJSON {
writeJSON(w, code, rp)
return
}
if rp.OK && f.Success != "" {
http.Redirect(w, r, f.Success, http.StatusSeeOther)
return
}
writeHTML(w, code, rp)
}
if !sameSite(r, rt) {
answer(http.StatusForbidden, reply{Message: "This form can only be sent from its own page."})
return
}
ip := s.clientIP(r)
if !s.limit.allow(host+"|"+limitKey(ip), s.now()) {
answer(http.StatusTooManyRequests, reply{Message: "Too many messages from your connection. Please wait a few minutes and try again."})
return
}
if ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); ct != "application/x-www-form-urlencoded" {
answer(http.StatusUnsupportedMediaType, reply{Message: "This form takes text fields only."})
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
if err := r.ParseForm(); err != nil {
answer(http.StatusRequestEntityTooLarge, reply{Message: "That's more than this form accepts."})
return
}
values := r.PostForm
// The trap field: a person never sees it; a bot fills in everything.
// Answer as if it worked, so the bot learns nothing.
if values.Get("_website") != "" {
s.log.Printf("%s %s: trap field filled, dropped", host, f.Name)
answer(http.StatusOK, reply{OK: true, Message: "Thanks, your message was sent."})
return
}
if err := s.tokens.check(values.Get("_t"), host, f.Name, s.now()); err != nil {
msg := "This form needs JavaScript to send. Please reload the page and try again."
if errors.Is(err, errTokenYoung) {
msg = "That was quicker than a person types. Please wait a moment and send again."
} else if errors.Is(err, errTokenOld) {
msg = "This page has been open a long time. Please reload it and send again."
}
answer(http.StatusForbidden, reply{Message: msg, Retoken: true})
return
}
if f.Turnstile != "" && !turnstileOK(r.Context(), s.turnstile, values.Get("cf-turnstile-response"), ip) {
answer(http.StatusForbidden, reply{Message: "Please complete the check above the button and send again."})
return
}
sub, problems := f.Check(values)
if problems != nil {
answer(http.StatusBadRequest, reply{Message: "Please check the highlighted fields.", Problems: problems})
return
}
stored := false
if f.Store {
if err := s.store.append(rt.cfg.Store, sub, s.now()); err != nil {
s.log.Printf("%s %s: storing failed: %v", host, f.Name, err)
} else {
stored = true
}
}
if err := s.mail.deliver(rt.site.Name, sub); err != nil {
s.log.Printf("%s %s: delivery failed: %v", host, f.Name, err)
if !stored {
msg := "The message couldn't be sent just now. Please try again later."
if f.Mailto != "" {
msg += " You can also email " + f.Mailto + "."
}
answer(http.StatusBadGateway, reply{Message: msg})
return
}
}
s.log.Printf("%s %s: sent", host, f.Name)
answer(http.StatusOK, reply{OK: true, Message: "Thanks, your message was sent."})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
var replyPage = template.Must(template.New("reply").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex"><title>{{ if .OK }}Sent{{ else }}Not sent{{ end }}</title>
<style>body{font:17px/1.6 system-ui,sans-serif;max-width:36rem;margin:3rem auto;padding:0 1rem}</style></head><body>
<h1>{{ if .OK }}Sent{{ else }}Not sent yet{{ end }}</h1>
<p>{{ .Message }}</p>
{{ with .Problems }}<ul>{{ range . }}<li>{{ .Message }}</li>{{ end }}</ul>{{ end }}
<p>Use your browser's Back button to return to the form; what you typed is still there.</p>
</body></html>
`))
func writeHTML(w http.ResponseWriter, code int, rp reply) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
_ = replyPage.Execute(w, rp)
}