Files
cv-site/internal/middleware/contact_rate_limit.go
T
juanatsap c89b67a06d refactor: consolidate lang into constants, rename services to email
- Merge lang package into constants (add IsValidLang, ValidateLang, AllLangs)
- Rename internal/services to internal/email for consistency with pdf package
- Rename types to avoid redundancy: EmailService→Service, EmailConfig→Config
- Update all imports and references across codebase
- Delete internal/lang directory (functions moved to constants)
2025-12-06 17:05:17 +00:00

134 lines
3.3 KiB
Go

package middleware
import (
"fmt"
"net/http"
"strings"
"sync"
"time"
c "github.com/juanatsap/cv-site/internal/constants"
)
// 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(c.HeaderXForwardedFor)
if ip == "" {
ip = r.Header.Get(c.HeaderXRealIP)
}
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(c.HeaderHXRequest) != ""
if isHTMX {
// Return HTMX-friendly error
w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
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(c.HeaderRetryAfter, fmt.Sprintf("%d", int(c.RateLimitContactWindow.Seconds())))
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
func (rl *ContactRateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
limit := c.RateLimitContactRequests
window := c.RateLimitContactWindow
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(c.RateLimitCleanupPeriod)
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": c.RateLimitContactRequests,
"window": c.RateLimitContactWindow.String(),
}
}