diff --git a/app/go.mod b/app/go.mod index 0e3ef45..49a2210 100644 --- a/app/go.mod +++ b/app/go.mod @@ -2,6 +2,8 @@ module technical.kiwi/website go 1.25.0 -require github.com/a-h/templ v0.3.1020 - require golang.org/x/image v0.41.0 + +require github.com/abadojack/whatlanggo v1.0.1 + +require github.com/a-h/templ v0.3.1020 diff --git a/app/go.sum b/app/go.sum index 8fc9e12..57db9b7 100644 --- a/app/go.sum +++ b/app/go.sum @@ -1,5 +1,7 @@ github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= +github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= diff --git a/app/internal/contact/validate.go b/app/internal/contact/validate.go index fa2cb9d..f295760 100644 --- a/app/internal/contact/validate.go +++ b/app/internal/contact/validate.go @@ -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 } diff --git a/app/internal/contact/validate_test.go b/app/internal/contact/validate_test.go index 822e683..c3c1270 100644 --- a/app/internal/contact/validate_test.go +++ b/app/internal/contact/validate_test.go @@ -12,16 +12,33 @@ func TestParse_valid(t *testing.T) { } } +func TestParse_normalizesEmail(t *testing.T) { + sub, errs := Parse("Jimmy", "Jane Doe ", "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") + } +} diff --git a/app/internal/contactcheck/english.go b/app/internal/contactcheck/english.go new file mode 100644 index 0000000..d3bed76 --- /dev/null +++ b/app/internal/contactcheck/english.go @@ -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 +} diff --git a/app/internal/contactcheck/form.go b/app/internal/contactcheck/form.go new file mode 100644 index 0000000..2c10e15 --- /dev/null +++ b/app/internal/contactcheck/form.go @@ -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 +} diff --git a/app/internal/contactcheck/form_test.go b/app/internal/contactcheck/form_test.go new file mode 100644 index 0000000..9ae4e09 --- /dev/null +++ b/app/internal/contactcheck/form_test.go @@ -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") + } +} diff --git a/app/internal/handlers/contact_antispam.go b/app/internal/handlers/contact_antispam.go new file mode 100644 index 0000000..6b72826 --- /dev/null +++ b/app/internal/handlers/contact_antispam.go @@ -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 +} diff --git a/app/internal/handlers/handlers.go b/app/internal/handlers/handlers.go index 897de89..987d7dc 100644 --- a/app/internal/handlers/handlers.go +++ b/app/internal/handlers/handlers.go @@ -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"), diff --git a/app/internal/middleware/middleware.go b/app/internal/middleware/middleware.go index 1d4593d..f737652 100644 --- a/app/internal/middleware/middleware.go +++ b/app/internal/middleware/middleware.go @@ -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 == "" { diff --git a/app/internal/ratelimit/ipwindow.go b/app/internal/ratelimit/ipwindow.go new file mode 100644 index 0000000..eafe7c9 --- /dev/null +++ b/app/internal/ratelimit/ipwindow.go @@ -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 +} diff --git a/app/static/style.css b/app/static/style.css index 6b33841..38a6d44 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -524,16 +524,49 @@ main { background: #000; } +.modal-video { + position: relative; + width: 100%; +} + .modal-figure img, .modal-figure video { display: block; width: 100%; max-height: 75vh; + min-height: 12rem; object-fit: contain; border-radius: 8px; background: #000; } +.modal-figure video.video-broken { + display: none; +} + +.video-unavailable { + margin: 0; + padding: 1.25rem; + text-align: center; + color: var(--text-muted); + background: #000; + border-radius: 8px; + min-height: 12rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.video-unavailable[hidden] { + display: none !important; +} + +.video-unavailable a { + color: var(--accent); +} + .modal-figure figcaption { margin-top: 0.75rem; color: var(--text-muted); diff --git a/app/templates/contact.templ b/app/templates/contact.templ index 459ee66..ecdca80 100644 --- a/app/templates/contact.templ +++ b/app/templates/contact.templ @@ -4,7 +4,7 @@ templ ContactForm(enabled bool) {

Contact

- Interested in a project or collaboration? Send a message below. + Interested in a project or collaboration? Send a message below. Messages must be in English.

if !enabled {

@@ -33,7 +33,7 @@ templ ContactForm(enabled bool) {

- +
hello@technical.kiwi.

} + +templ ContactRateLimited() { + +} diff --git a/app/templates/gallery.templ b/app/templates/gallery.templ index c518237..70adca6 100644 --- a/app/templates/gallery.templ +++ b/app/templates/gallery.templ @@ -65,7 +65,15 @@ templ ImageModal(img gallery.Image, prevPath, nextPath string) {