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.