Files
cv-site/internal/handlers/contact.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

230 lines
6.2 KiB
Go

package handlers
import (
"fmt"
"log"
"net/http"
"strings"
"time"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates"
)
// EmailSender is an interface for sending contact form emails
// This allows for easy mocking in tests
type EmailSender interface {
SendContactForm(data *email.ContactFormData) error
}
// ContactHandler handles contact form submissions
type ContactHandler struct {
templates *templates.Manager
emailService EmailSender
}
// NewContactHandler creates a new contact handler
func NewContactHandler(tmpl *templates.Manager, emailService EmailSender) *ContactHandler {
return &ContactHandler{
templates: tmpl,
emailService: emailService,
}
}
// ContactFormRequest represents the contact form submission
type ContactFormRequest struct {
Email string
Name string
Company string
Subject string
Message string
Honeypot string // Hidden field - should be empty
SubmitTime time.Time // Set by client, checked server-side
CSRFToken string
}
// Submit handles POST /api/contact
func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) {
// Only accept POST
if r.Method != http.MethodPost {
HandleError(w, r, NewAppError(nil, "Method not allowed", http.StatusMethodNotAllowed, false))
return
}
// Parse form
if err := r.ParseForm(); err != nil {
log.Printf("ERROR parsing contact form: %v", err)
h.renderError(w, r, "Invalid form data. Please try again.")
return
}
// Extract form data
req := &ContactFormRequest{
Email: strings.TrimSpace(r.FormValue("email")),
Name: strings.TrimSpace(r.FormValue("name")),
Company: strings.TrimSpace(r.FormValue("company")),
Subject: strings.TrimSpace(r.FormValue("subject")),
Message: strings.TrimSpace(r.FormValue("message")),
Honeypot: r.FormValue("website"), // Honeypot field
CSRFToken: r.FormValue("csrf_token"),
}
// Bot protection: Honeypot check
if req.Honeypot != "" {
log.Printf("SECURITY: Honeypot triggered from IP %s", getClientIP(r))
// Don't reveal that we detected a bot - just show success
h.renderSuccess(w, r)
return
}
// Bot protection: Timing check
submitTimeStr := r.FormValue("submit_time")
if submitTimeStr != "" {
// Parse submit time (Unix timestamp in milliseconds)
var submitTimeMs int64
if _, err := fmt.Sscanf(submitTimeStr, "%d", &submitTimeMs); err == nil {
submitTime := time.Unix(0, submitTimeMs*int64(time.Millisecond))
elapsed := time.Since(submitTime)
// Reject if submitted too fast (< 2 seconds)
if elapsed < c.FormMinSubmitTime {
log.Printf("SECURITY: Form submitted too fast (%v) from IP %s", elapsed, getClientIP(r))
h.renderError(w, r, "Please take your time filling out the form.")
return
}
}
}
// CSRF validation is handled by middleware
// Validate required fields
if req.Email == "" {
h.renderError(w, r, "Email address is required.")
return
}
if req.Message == "" {
h.renderError(w, r, "Message is required.")
return
}
// Create email data
emailData := &email.ContactFormData{
Email: req.Email,
Name: req.Name,
Company: req.Company,
Subject: req.Subject,
Message: req.Message,
IP: getClientIP(r),
Time: time.Now(),
}
// Send email
if err := h.emailService.SendContactForm(emailData); err != nil {
log.Printf("ERROR sending contact email: %v", err)
// Check if it's a validation error or server error
if strings.Contains(err.Error(), "validation failed") {
h.renderError(w, r, err.Error())
return
}
// Internal server error
h.renderError(w, r, "Failed to send message. Please try again later.")
return
}
// Log successful submission (without sensitive data)
log.Printf("Contact form submitted successfully from %s (%s)", req.Email, getClientIP(r))
// Render success response
h.renderSuccess(w, r)
}
// renderSuccess renders the success partial
func (h *ContactHandler) renderSuccess(w http.ResponseWriter, r *http.Request) {
w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
w.WriteHeader(http.StatusOK)
// Fallback HTML for when templates aren't available (e.g., in tests)
fallbackHTML := `<div class="alert alert-success">
<h3>Message Sent!</h3>
<p>Thank you for your message. I'll get back to you soon.</p>
</div>`
// Check if templates are properly initialized
if !h.templates.IsInitialized() {
_, _ = w.Write([]byte(fallbackHTML))
return
}
tmpl, err := h.templates.Render("contact-success")
if err != nil {
log.Printf("ERROR loading success template: %v", err)
_, _ = w.Write([]byte(fallbackHTML))
return
}
if err := tmpl.Execute(w, nil); err != nil {
log.Printf("ERROR rendering error template: %v", err)
_, _ = w.Write([]byte(fallbackHTML))
}
}
// renderError renders the error partial
func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, message string) {
w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
w.WriteHeader(http.StatusBadRequest)
// Fallback HTML for when templates aren't available (e.g., in tests)
fallbackHTML := `<div class="alert alert-error"><h3>Error</h3><p>` + message + `</p></div>`
// Check if templates are properly initialized
if !h.templates.IsInitialized() {
_, _ = w.Write([]byte(fallbackHTML))
return
}
data := map[string]interface{}{
"Message": message,
}
tmpl, err := h.templates.Render("contact-error")
if err != nil {
log.Printf("ERROR loading error template: %v", err)
_, _ = w.Write([]byte(fallbackHTML))
return
}
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR rendering error template: %v", err)
_, _ = w.Write([]byte(fallbackHTML))
}
}
// getClientIP extracts the client IP address from the request
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header (for proxies)
ip := r.Header.Get(c.HeaderXForwardedFor)
if ip != "" {
// X-Forwarded-For can contain multiple IPs, take the first one
ips := strings.Split(ip, ",")
return strings.TrimSpace(ips[0])
}
// Check X-Real-IP header
ip = r.Header.Get(c.HeaderXRealIP)
if ip != "" {
return ip
}
// Fallback to RemoteAddr
ip = r.RemoteAddr
// Remove port if present
if colonIndex := strings.LastIndex(ip, ":"); colonIndex != -1 {
ip = ip[:colonIndex]
}
return ip
}