f91a24ea9b
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
132 lines
3.1 KiB
Go
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",
|
|
}
|
|
}
|