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:
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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 (
|
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"),
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user