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:
2026-06-04 00:38:48 +12:00
parent a9095727bf
commit 6c215d40e6
16 changed files with 385 additions and 16 deletions

View File

@@ -2,6 +2,8 @@ module technical.kiwi/website
go 1.25.0 go 1.25.0
require github.com/a-h/templ v0.3.1020
require golang.org/x/image v0.41.0 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

View File

@@ -1,5 +1,7 @@
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= 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/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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=

View File

@@ -1,16 +1,19 @@
package contact package contact
import ( import (
"errors"
"net/mail" "net/mail"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"technical.kiwi/website/internal/contactcheck"
) )
const ( const (
maxNameLen = 120 maxNameLen = 120
maxEmailLen = 254 maxEmailLen = 254
maxMessageLen = 8000 maxMessageLen = 8000
minMessageLen = 10 minMessageLen = 20
) )
// Submission is validated contact form input. // Submission is validated contact form input.
@@ -45,18 +48,40 @@ func Parse(name, email, message string) (Submission, Errors) {
errs["email"] = "Email is required." errs["email"] = "Email is required."
} else if utf8.RuneCountInString(email) > maxEmailLen { } else if utf8.RuneCountInString(email) > maxEmailLen {
errs["email"] = "Email is too long." errs["email"] = "Email is too long."
} else if _, err := mail.ParseAddress(email); err != nil { } else {
addr, err := mail.ParseAddress(email)
if err != nil {
errs["email"] = "Enter a valid email address." errs["email"] = "Enter a valid email address."
} else {
email = addr.Address
}
} }
if message == "" { if message == "" {
errs["message"] = "Message is required." errs["message"] = "Message is required."
} else if utf8.RuneCountInString(message) < minMessageLen { } 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 { } else if utf8.RuneCountInString(message) > maxMessageLen {
errs["message"] = "Message is too long." 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() { if errs.Any() {
return Submission{}, errs return Submission{}, errs
} }

View File

@@ -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) { func TestParse_shortMessage(t *testing.T) {
_, errs := Parse("Jimmy", "jim@example.com", "short") _, errs := Parse("Jimmy", "jim@example.com", "short")
if !errs.Any() { if errs["message"] == "" {
t.Fatal("expected validation error") t.Fatal("expected message error")
} }
} }
func TestParse_invalidEmail(t *testing.T) { 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"] == "" { if errs["email"] == "" {
t.Fatal("expected email error") 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")
}
}

View 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
}

View 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
}

View 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")
}
}

View 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
}

View File

@@ -3,15 +3,16 @@ package handlers
import ( import (
"log" "log"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
"technical.kiwi/website/internal/auth" "technical.kiwi/website/internal/auth"
"technical.kiwi/website/internal/contact" "technical.kiwi/website/internal/contact"
"technical.kiwi/website/internal/contactcheck"
"technical.kiwi/website/internal/gallery" "technical.kiwi/website/internal/gallery"
"technical.kiwi/website/internal/mail" "technical.kiwi/website/internal/mail"
"technical.kiwi/website/internal/middleware" "technical.kiwi/website/internal/middleware"
"technical.kiwi/website/internal/ratelimit"
"technical.kiwi/website/templates" "technical.kiwi/website/templates"
) )
@@ -26,6 +27,7 @@ type Server struct {
ContactEnabled bool ContactEnabled bool
Auth auth.Config Auth auth.Config
Sessions *auth.Sessions Sessions *auth.Sessions
contactRL *ratelimit.IPWindow
galleryMu sync.RWMutex galleryMu sync.RWMutex
} }
@@ -59,6 +61,7 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
ContactEnabled: mailCfg != nil, ContactEnabled: mailCfg != nil,
Auth: authCfg, Auth: authCfg,
Sessions: auth.NewSessions(), Sessions: auth.NewSessions(),
contactRL: ratelimit.NewIPWindow(5, time.Hour),
} }
if authCfg.Enabled { if authCfg.Enabled {
log.Print("admin: enabled at /admin/") 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) { func (s *Server) index(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
if s.ContactEnabled {
setContactFormSeen(w)
}
images, hero, hasHero := s.snapshot() images, hero, hasHero := s.snapshot()
templates.Home(images, hero, hasHero, s.ContactEnabled).Render(r.Context(), w) 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 return
} }
if strings.TrimSpace(r.FormValue("website")) != "" { if contactcheck.SpamHoneypot(r.FormValue(contactcheck.HoneypotField)) {
templates.ContactSuccess().Render(r.Context(), w) templates.ContactSuccess().Render(r.Context(), w)
return 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( sub, errs := contact.Parse(
r.FormValue("name"), r.FormValue("name"),
r.FormValue("email"), r.FormValue("email"),

View File

@@ -46,6 +46,7 @@ func CacheStatic(dir string, maxAge time.Duration) http.Handler {
// CacheImages wraps image serving; thumbs cache longer than originals. // CacheImages wraps image serving; thumbs cache longer than originals.
func CacheImages(dir string) http.Handler { func CacheImages(dir string) http.Handler {
fs := http.FileServer(http.Dir(dir))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, "/") rel := strings.TrimPrefix(r.URL.Path, "/")
if strings.HasPrefix(rel, "thumbs/") || strings.HasPrefix(rel, "hero/") { if strings.HasPrefix(rel, "thumbs/") || strings.HasPrefix(rel, "hero/") {
@@ -53,10 +54,28 @@ func CacheImages(dir string) http.Handler {
} else { } else {
w.Header().Set("Cache-Control", "public, max-age=86400") 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 { func formatMaxAge(d time.Duration) string {
return strconv.Itoa(int(d.Seconds())) return strconv.Itoa(int(d.Seconds()))
} }
@@ -66,6 +85,10 @@ func shouldSkipGzip(r *http.Request) bool {
if r.Header.Get("Range") != "" { if r.Header.Get("Range") != "" {
return true 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)) ext := strings.ToLower(filepath.Ext(r.URL.Path))
if ext == "" { if ext == "" {

View 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
}

View File

@@ -524,16 +524,49 @@ main {
background: #000; background: #000;
} }
.modal-video {
position: relative;
width: 100%;
}
.modal-figure img, .modal-figure img,
.modal-figure video { .modal-figure video {
display: block; display: block;
width: 100%; width: 100%;
max-height: 75vh; max-height: 75vh;
min-height: 12rem;
object-fit: contain; object-fit: contain;
border-radius: 8px; border-radius: 8px;
background: #000; 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 { .modal-figure figcaption {
margin-top: 0.75rem; margin-top: 0.75rem;
color: var(--text-muted); color: var(--text-muted);

View File

@@ -4,7 +4,7 @@ templ ContactForm(enabled bool) {
<section id="contact" class="contact"> <section id="contact" class="contact">
<h2>Contact</h2> <h2>Contact</h2>
<p class="contact-intro"> <p class="contact-intro">
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.
</p> </p>
if !enabled { if !enabled {
<p class="contact-unavailable"> <p class="contact-unavailable">
@@ -33,7 +33,7 @@ templ ContactForm(enabled bool) {
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="message">Message</label> <label for="message">Message</label>
<textarea id="message" name="message" required rows="6" maxlength="8000"></textarea> <textarea id="message" name="message" required minlength="20" rows="6" maxlength="8000" placeholder="Write at least a few sentences in English."></textarea>
</div> </div>
<input <input
class="hp-field" class="hp-field"
@@ -74,3 +74,9 @@ templ ContactSendError() {
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>.</p> <a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>.</p>
</div> </div>
} }
templ ContactRateLimited() {
<div class="alert alert-error" role="alert">
<p>Too many messages from your network. Please try again later.</p>
</div>
}

View File

@@ -65,7 +65,15 @@ templ ImageModal(img gallery.Image, prevPath, nextPath string) {
</button> </button>
<figure class="modal-figure"> <figure class="modal-figure">
if img.IsVideo { if img.IsVideo {
<video src={ img.URL } controls playsinline preload="metadata"></video> <div class="modal-video">
<video controls playsinline preload="auto" poster={ img.ThumbURL }>
<source src={ img.URL } type={ videoMIME(img.Filename) }/>
</video>
<p class="video-unavailable" hidden>
Playback is not supported in this browser.
<a href={ img.URL } download={ img.Filename }>Download the video</a>
</p>
</div>
} else { } else {
<img src={ img.URL } alt={ img.Filename }/> <img src={ img.URL } alt={ img.Filename }/>
} }

View File

@@ -1,7 +1,22 @@
package templates package templates
import "time" import (
"path/filepath"
"strings"
"time"
)
func currentYear() string { func currentYear() string {
return time.Now().Format("2006") return time.Now().Format("2006")
} }
func videoMIME(filename string) string {
switch strings.ToLower(filepath.Ext(filename)) {
case ".webm":
return "video/webm"
case ".mov":
return "video/quicktime"
default:
return "video/mp4"
}
}

View File

@@ -70,8 +70,25 @@ templ Layout(title string, preloadImage string, content templ.Component) {
hydrateLazyThumbs(document); hydrateLazyThumbs(document);
}); });
function initModalVideos(root) {
root.querySelectorAll(".modal-video video").forEach(function (video) {
var wrap = video.closest(".modal-video");
var notice = wrap && wrap.querySelector(".video-unavailable");
video.addEventListener(
"error",
function () {
video.classList.add("video-broken");
if (notice) notice.hidden = false;
},
{ once: true },
);
video.load();
});
}
document.body.addEventListener("htmx:afterSwap", function (event) { document.body.addEventListener("htmx:afterSwap", function (event) {
hydrateLazyThumbs(event.target); hydrateLazyThumbs(event.target);
initModalVideos(event.target);
}); });
})(); })();
</script> </script>