Files
cv-site/internal/middleware/contact_rate_limit.go
T
juanatsap f91a24ea9b feat: Add plain text CV endpoint and contact form with security
Plain text endpoint:
- Add /text route for plain text CV (for curl/AI crawlers)
- Use k3a/html2text library for HTML-to-text conversion
- Add Plain Text button to hamburger menu with UI translations

Contact form feature:
- Add ContactHandler with proper email service integration
- Add CSRF protection middleware
- Add rate limiting (5 submissions/hour per IP)
- Add honeypot and timing-based bot protection
- Add input validation with detailed error messages
- Add security logging middleware
- Add browser-only middleware for API protection

Code quality:
- Fix all golangci-lint errcheck warnings for w.Write calls
- Remove duplicate getClientIP functions
- Wire up ContactHandler in routes.Setup
2025-11-30 13:47:49 +00:00

132 lines
3.1 KiB
Go

package middleware
import (
"net/http"
"strings"
"sync"
"time"
)
// contactRateLimitEntry tracks rate limiting for contact form per IP
type contactRateLimitEntry struct {
count int
resetTime time.Time
}
// ContactRateLimiter provides rate limiting specifically for contact form
// Allows 5 submissions per hour per IP address
type ContactRateLimiter struct {
mu sync.RWMutex
clients map[string]*contactRateLimitEntry
}
// NewContactRateLimiter creates a new contact form rate limiter
// Default: 5 requests per hour per IP
func NewContactRateLimiter() *ContactRateLimiter {
rl := &ContactRateLimiter{
clients: make(map[string]*contactRateLimitEntry),
}
// Cleanup expired entries every 10 minutes
go rl.cleanup()
return rl
}
// Middleware returns rate limiting middleware for contact form
func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get client IP (handle X-Forwarded-For for proxies)
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.Header.Get("X-Real-IP")
}
if ip == "" {
ip = strings.Split(r.RemoteAddr, ":")[0]
}
// Extract first IP if multiple IPs in X-Forwarded-For
if strings.Contains(ip, ",") {
ip = strings.TrimSpace(strings.Split(ip, ",")[0])
}
if !rl.allow(ip) {
// Check if HTMX request
isHTMX := r.Header.Get("HX-Request") != ""
if isHTMX {
// Return HTMX-friendly error
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`<div class="alert alert-error">
<h3>Too Many Requests</h3>
<p>You've submitted too many contact forms. Please wait an hour before trying again.</p>
</div>`))
} else {
w.Header().Set("Retry-After", "3600") // 1 hour
http.Error(w, "Too many contact form submissions. Please try again in an hour.", http.StatusTooManyRequests)
}
return
}
next.ServeHTTP(w, r)
})
}
// allow checks if the request is allowed based on rate limit
// Limit: 5 submissions per hour
func (rl *ContactRateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
limit := 5
window := 1 * time.Hour
entry, exists := rl.clients[ip]
if !exists || now.After(entry.resetTime) {
// New client or window expired
rl.clients[ip] = &contactRateLimitEntry{
count: 1,
resetTime: now.Add(window),
}
return true
}
if entry.count >= limit {
return false
}
entry.count++
return true
}
// cleanup removes expired entries periodically
func (rl *ContactRateLimiter) cleanup() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
now := time.Now()
for ip, entry := range rl.clients {
if now.After(entry.resetTime) {
delete(rl.clients, ip)
}
}
rl.mu.Unlock()
}
}
// GetStats returns current rate limit statistics (for monitoring/debugging)
func (rl *ContactRateLimiter) GetStats() map[string]interface{} {
rl.mu.RLock()
defer rl.mu.RUnlock()
return map[string]interface{}{
"total_clients": len(rl.clients),
"limit": 5,
"window": "1 hour",
}
}