Add contact antispam and fix gallery video playback.
English-only messages, rate limiting, min fill time, and normalized email validation; improve modal video serving with posters, correct MIME types, and no gzip on gallery media. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"technical.kiwi/website/internal/contactcheck"
|
||||
)
|
||||
|
||||
const (
|
||||
maxNameLen = 120
|
||||
maxEmailLen = 254
|
||||
maxMessageLen = 8000
|
||||
minMessageLen = 10
|
||||
minMessageLen = 20
|
||||
)
|
||||
|
||||
// Submission is validated contact form input.
|
||||
@@ -45,18 +48,40 @@ func Parse(name, email, message string) (Submission, Errors) {
|
||||
errs["email"] = "Email is required."
|
||||
} else if utf8.RuneCountInString(email) > maxEmailLen {
|
||||
errs["email"] = "Email is too long."
|
||||
} else if _, err := mail.ParseAddress(email); err != nil {
|
||||
errs["email"] = "Enter a valid email address."
|
||||
} else {
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
errs["email"] = "Enter a valid email address."
|
||||
} else {
|
||||
email = addr.Address
|
||||
}
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
errs["message"] = "Message is required."
|
||||
} else if utf8.RuneCountInString(message) < minMessageLen {
|
||||
errs["message"] = "Message must be at least 10 characters."
|
||||
errs["message"] = "Message must be at least 20 characters."
|
||||
} else if utf8.RuneCountInString(message) > maxMessageLen {
|
||||
errs["message"] = "Message is too long."
|
||||
}
|
||||
|
||||
if !errs.Any() {
|
||||
if err := contactcheck.ValidateEnglish(name, message); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, contactcheck.ErrTooShort):
|
||||
errs["message"] = "Message must be at least 20 characters."
|
||||
case errors.Is(err, contactcheck.ErrTooLong):
|
||||
errs["message"] = "Message is too long."
|
||||
case errors.Is(err, contactcheck.ErrName):
|
||||
errs["name"] = "Name is too long."
|
||||
case errors.Is(err, contactcheck.ErrNotEnglish):
|
||||
errs["message"] = "This site only accepts messages written in English. If your message is in English, try adding a few more clear sentences so we can detect the language reliably."
|
||||
default:
|
||||
errs["message"] = "Could not accept this message."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errs.Any() {
|
||||
return Submission{}, errs
|
||||
}
|
||||
|
||||
@@ -12,16 +12,33 @@ func TestParse_valid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_normalizesEmail(t *testing.T) {
|
||||
sub, errs := Parse("Jimmy", "Jane Doe <jane@example.com>", "Hello there, this is a test message.")
|
||||
if errs.Any() {
|
||||
t.Fatalf("unexpected errors: %v", errs)
|
||||
}
|
||||
if sub.Email != "jane@example.com" {
|
||||
t.Fatalf("email = %q", sub.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_shortMessage(t *testing.T) {
|
||||
_, errs := Parse("Jimmy", "jim@example.com", "short")
|
||||
if !errs.Any() {
|
||||
t.Fatal("expected validation error")
|
||||
if errs["message"] == "" {
|
||||
t.Fatal("expected message error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_invalidEmail(t *testing.T) {
|
||||
_, errs := Parse("Jimmy", "not-an-email", "This message is long enough to pass.")
|
||||
_, errs := Parse("Jimmy", "not-an-email", "This message is long enough to pass validation here.")
|
||||
if errs["email"] == "" {
|
||||
t.Fatal("expected email error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_notEnglish(t *testing.T) {
|
||||
_, errs := Parse("Jimmy", "jim@example.com", "これは日本語のテストメッセージです。十分な長さがあります。")
|
||||
if errs["message"] == "" {
|
||||
t.Fatal("expected English-only message error")
|
||||
}
|
||||
}
|
||||
|
||||
51
app/internal/contactcheck/english.go
Normal file
51
app/internal/contactcheck/english.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package contactcheck
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/abadojack/whatlanggo"
|
||||
)
|
||||
|
||||
const (
|
||||
minMessageRunes = 20
|
||||
maxMessageRunes = 8000
|
||||
maxNameRunes = 120
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotEnglish = errors.New("message must be in English")
|
||||
ErrTooShort = errors.New("message too short")
|
||||
ErrTooLong = errors.New("message too long")
|
||||
ErrName = errors.New("name too long")
|
||||
)
|
||||
|
||||
// ValidateEnglish checks script and language for name + message combined.
|
||||
func ValidateEnglish(name, message string) error {
|
||||
msg := strings.TrimSpace(message)
|
||||
nMsg := len([]rune(msg))
|
||||
if nMsg < minMessageRunes {
|
||||
return ErrTooShort
|
||||
}
|
||||
if nMsg > maxMessageRunes {
|
||||
return ErrTooLong
|
||||
}
|
||||
if len([]rune(strings.TrimSpace(name))) > maxNameRunes {
|
||||
return ErrName
|
||||
}
|
||||
|
||||
combined := strings.TrimSpace(name) + "\n\n" + msg
|
||||
info := whatlanggo.Detect(combined)
|
||||
|
||||
if info.Script != nil && info.Script != unicode.Latin {
|
||||
return ErrNotEnglish
|
||||
}
|
||||
if info.Lang != whatlanggo.Eng {
|
||||
return ErrNotEnglish
|
||||
}
|
||||
if !info.IsReliable() && info.Confidence < 0.55 {
|
||||
return ErrNotEnglish
|
||||
}
|
||||
return nil
|
||||
}
|
||||
25
app/internal/contactcheck/form.go
Normal file
25
app/internal/contactcheck/form.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package contactcheck
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HoneypotField is the form field bots should leave blank (hidden from users).
|
||||
const HoneypotField = "website"
|
||||
|
||||
// MinFormFillDuration is the minimum time between showing the form and submit.
|
||||
const MinFormFillDuration = 3 * time.Second
|
||||
|
||||
// SpamHoneypot reports whether the honeypot was filled (likely spam).
|
||||
func SpamHoneypot(value string) bool {
|
||||
return strings.TrimSpace(value) != ""
|
||||
}
|
||||
|
||||
// FormFilledTooFast reports whether the form was submitted before seenUnix (0 = never seen).
|
||||
func FormFilledTooFast(seenUnix int64, now time.Time) bool {
|
||||
if seenUnix <= 0 {
|
||||
return true
|
||||
}
|
||||
return now.Sub(time.Unix(seenUnix, 0)) < MinFormFillDuration
|
||||
}
|
||||
30
app/internal/contactcheck/form_test.go
Normal file
30
app/internal/contactcheck/form_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package contactcheck
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSpamHoneypot(t *testing.T) {
|
||||
if !SpamHoneypot("filled") {
|
||||
t.Fatal("expected honeypot trip")
|
||||
}
|
||||
if SpamHoneypot(" ") {
|
||||
t.Fatal("expected empty honeypot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormFilledTooFast(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
seen := now.Add(-MinFormFillDuration).Unix()
|
||||
|
||||
if !FormFilledTooFast(0, now) {
|
||||
t.Error("missing seen time should be too fast")
|
||||
}
|
||||
if !FormFilledTooFast(seen+1, now) {
|
||||
t.Error("submit before min duration should be too fast")
|
||||
}
|
||||
if FormFilledTooFast(seen, now) {
|
||||
t.Error("submit at min duration should be allowed")
|
||||
}
|
||||
}
|
||||
50
app/internal/handlers/contact_antispam.go
Normal file
50
app/internal/handlers/contact_antispam.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const contactSeenCookie = "tk_contact_seen"
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.Index(xff, ","); i >= 0 {
|
||||
xff = xff[:i]
|
||||
}
|
||||
if ip := strings.TrimSpace(xff); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func setContactFormSeen(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: contactSeenCookie,
|
||||
Value: strconv.FormatInt(time.Now().Unix(), 10),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int((10 * time.Minute).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func contactFormSeenUnix(r *http.Request) int64 {
|
||||
c, err := r.Cookie(contactSeenCookie)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseInt(c.Value, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -3,15 +3,16 @@ package handlers
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"technical.kiwi/website/internal/auth"
|
||||
"technical.kiwi/website/internal/contact"
|
||||
"technical.kiwi/website/internal/contactcheck"
|
||||
"technical.kiwi/website/internal/gallery"
|
||||
"technical.kiwi/website/internal/mail"
|
||||
"technical.kiwi/website/internal/middleware"
|
||||
"technical.kiwi/website/internal/ratelimit"
|
||||
"technical.kiwi/website/templates"
|
||||
)
|
||||
|
||||
@@ -26,6 +27,7 @@ type Server struct {
|
||||
ContactEnabled bool
|
||||
Auth auth.Config
|
||||
Sessions *auth.Sessions
|
||||
contactRL *ratelimit.IPWindow
|
||||
galleryMu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
|
||||
ContactEnabled: mailCfg != nil,
|
||||
Auth: authCfg,
|
||||
Sessions: auth.NewSessions(),
|
||||
contactRL: ratelimit.NewIPWindow(5, time.Hour),
|
||||
}
|
||||
if authCfg.Enabled {
|
||||
log.Print("admin: enabled at /admin/")
|
||||
@@ -92,6 +95,9 @@ func (s *Server) snapshot() ([]gallery.Image, gallery.Image, bool) {
|
||||
|
||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if s.ContactEnabled {
|
||||
setContactFormSeen(w)
|
||||
}
|
||||
images, hero, hasHero := s.snapshot()
|
||||
templates.Home(images, hero, hasHero, s.ContactEnabled).Render(r.Context(), w)
|
||||
}
|
||||
@@ -107,11 +113,21 @@ func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(r.FormValue("website")) != "" {
|
||||
if contactcheck.SpamHoneypot(r.FormValue(contactcheck.HoneypotField)) {
|
||||
templates.ContactSuccess().Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
if contactcheck.FormFilledTooFast(contactFormSeenUnix(r), time.Now()) {
|
||||
templates.ContactSuccess().Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.contactRL.Allow(clientIP(r)) {
|
||||
templates.ContactRateLimited().Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
sub, errs := contact.Parse(
|
||||
r.FormValue("name"),
|
||||
r.FormValue("email"),
|
||||
|
||||
@@ -46,6 +46,7 @@ func CacheStatic(dir string, maxAge time.Duration) http.Handler {
|
||||
|
||||
// CacheImages wraps image serving; thumbs cache longer than originals.
|
||||
func CacheImages(dir string) http.Handler {
|
||||
fs := http.FileServer(http.Dir(dir))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if strings.HasPrefix(rel, "thumbs/") || strings.HasPrefix(rel, "hero/") {
|
||||
@@ -53,10 +54,28 @@ func CacheImages(dir string) http.Handler {
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
}
|
||||
http.FileServer(http.Dir(dir)).ServeHTTP(w, r)
|
||||
if ct := mediaContentType(r.URL.Path); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func mediaContentType(urlPath string) string {
|
||||
switch strings.ToLower(filepath.Ext(urlPath)) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".webm":
|
||||
return "video/webm"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatMaxAge(d time.Duration) string {
|
||||
return strconv.Itoa(int(d.Seconds()))
|
||||
}
|
||||
@@ -66,6 +85,10 @@ func shouldSkipGzip(r *http.Request) bool {
|
||||
if r.Header.Get("Range") != "" {
|
||||
return true
|
||||
}
|
||||
// Gallery originals and derivatives must never be gzip-compressed.
|
||||
if strings.HasPrefix(r.URL.Path, "/images/") {
|
||||
return true
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(r.URL.Path))
|
||||
if ext == "" {
|
||||
|
||||
49
app/internal/ratelimit/ipwindow.go
Normal file
49
app/internal/ratelimit/ipwindow.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IPWindow limits how often the same IP may perform an action.
|
||||
type IPWindow struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
max int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func NewIPWindow(max int, window time.Duration) *IPWindow {
|
||||
return &IPWindow{
|
||||
hits: make(map[string][]time.Time),
|
||||
max: max,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow returns false if this IP has exceeded the limit.
|
||||
func (w *IPWindow) Allow(ip string) bool {
|
||||
if ip == "" {
|
||||
ip = "unknown"
|
||||
}
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-w.window)
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
ts := w.hits[ip]
|
||||
kept := ts[:0]
|
||||
for _, t := range ts {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= w.max {
|
||||
w.hits[ip] = kept
|
||||
return false
|
||||
}
|
||||
kept = append(kept, now)
|
||||
w.hits[ip] = kept
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user