c89b67a06d
- 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)
134 lines
3.3 KiB
Go
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(),
|
|
}
|
|
}
|