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

257 lines
7.7 KiB
Go

package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/httputil"
"github.com/juanatsap/cv-site/internal/email"
)
// ==============================================================================
// CONTACT FORM SUBMISSION HANDLER
// Part of CVHandler - handles POST /api/contact
// ==============================================================================
// ContactFormData represents the contact form submission
type ContactFormData struct {
Email string
Name string
Company string
Subject string
Message string
Website string // Honeypot field - should be empty
FormLoadedAt string // Timing field - Unix timestamp in milliseconds
Lang string
}
// HandleContact handles contact form submissions
func (h *CVHandler) HandleContact(w http.ResponseWriter, r *http.Request) {
if !httputil.RequirePost(w, r) {
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
log.Printf("Error parsing contact form: %v", err)
h.renderContactError(w, r, "Invalid form data. Please try again.")
return
}
lang := httputil.Lang(r)
// Extract form data
formData := &ContactFormData{
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")),
Website: r.FormValue("website"), // Honeypot
FormLoadedAt: r.FormValue("form_loaded_at"), // Timing
Lang: lang,
}
// Validate form data (includes bot protection)
if err := validateContactForm(formData, r); err != nil {
log.Printf("Contact form validation failed from IP %s: %v", getClientIP(r), err)
// Don't reveal specific errors to potential bots
if strings.Contains(err.Error(), "spam detected") {
// Silently succeed for bots
h.renderContactSuccess(w, r, lang)
return
}
h.renderContactError(w, r, err.Error())
return
}
// Log the contact form submission
log.Printf("Contact form submission from %s (IP: %s)", formData.Email, getClientIP(r))
log.Printf(" Name: %s, Company: %s", formData.Name, formData.Company)
log.Printf(" Subject: %s", formData.Subject)
log.Printf(" Message length: %d characters", len(formData.Message))
// Send email via EmailService
if h.emailService != nil {
emailData := &email.ContactFormData{
Email: formData.Email,
Name: formData.Name,
Company: formData.Company,
Subject: formData.Subject,
Message: formData.Message,
IP: getClientIP(r),
Time: time.Now(),
}
if err := h.emailService.SendContactForm(emailData); err != nil {
log.Printf("ERROR sending contact email: %v", err)
h.renderContactError(w, r, "Failed to send message. Please try again later.")
return
}
log.Printf("Contact email sent successfully to configured recipient")
} else {
log.Printf("WARNING: Email service not configured, skipping email send")
}
// Render success response
h.renderContactSuccess(w, r, lang)
}
// validateContactForm validates the contact form data and performs bot protection
func validateContactForm(data *ContactFormData, r *http.Request) error {
// Bot protection: Honeypot check - website field should be empty
if data.Website != "" {
return fmt.Errorf("spam detected: honeypot field filled")
}
// Bot protection: Timing check - form should take at least 2 seconds to fill
if data.FormLoadedAt != "" {
loadedAt, err := strconv.ParseInt(data.FormLoadedAt, 10, 64)
if err == nil {
now := time.Now().UnixMilli()
elapsed := now - loadedAt
// Form filled too quickly (< 2 seconds) - likely a bot
if elapsed < 2000 {
return fmt.Errorf("spam detected: form filled too quickly (%dms)", elapsed)
}
// Form took too long (> 1 hour) - timestamp expired
if elapsed > 3600000 {
return fmt.Errorf("form session expired, please refresh and try again")
}
}
}
// Required field validation
if data.Email == "" {
return fmt.Errorf("email address is required")
}
if data.Message == "" {
return fmt.Errorf("message is required")
}
// Email format validation (basic)
if !strings.Contains(data.Email, "@") || !strings.Contains(data.Email, ".") {
return fmt.Errorf("invalid email format")
}
// Message length validation
if len(data.Message) < 10 {
return fmt.Errorf("message is too short (minimum 10 characters)")
}
if len(data.Message) > 5000 {
return fmt.Errorf("message is too long (maximum 5000 characters)")
}
return nil
}
// renderContactSuccess renders the contact success partial
func (h *CVHandler) renderContactSuccess(w http.ResponseWriter, r *http.Request, lang string) {
// Get UI data from cache
ui := h.dataCache.GetUI(lang)
if ui == nil {
log.Printf("Error: UI data not found in cache for language: %s", lang)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Create template data
data := map[string]interface{}{
"UI": ui,
"Lang": lang,
}
// Render the success template
w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
w.WriteHeader(http.StatusOK)
tmpl, err := h.templates.Render("contact-success")
if err != nil {
log.Printf("Error loading contact success template: %v", err)
// Fallback to simple HTML
_, _ = w.Write([]byte(`<div class="contact-message contact-success">
<iconify-icon icon="mdi:check-circle" width="24" height="24"></iconify-icon>
<div class="contact-message-content">
<strong>Message Sent!</strong>
<p>Thank you for your message. I'll get back to you soon.</p>
</div>
</div>`))
return
}
if err := tmpl.Execute(w, data); err != nil {
log.Printf("Error rendering contact success template: %v", err)
_, _ = w.Write([]byte(`<div class="contact-message contact-success">
<iconify-icon icon="mdi:check-circle" width="24" height="24"></iconify-icon>
<div class="contact-message-content">
<strong>Message Sent!</strong>
<p>Thank you for your message. I'll get back to you soon.</p>
</div>
</div>`))
}
}
// renderContactError renders the contact error partial
func (h *CVHandler) renderContactError(w http.ResponseWriter, r *http.Request, errorMessage string) {
lang := httputil.Lang(r)
// Get UI data from cache
ui := h.dataCache.GetUI(lang)
if ui == nil {
log.Printf("Error: UI data not found in cache for language: %s", lang)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Create template data
data := map[string]interface{}{
"UI": ui,
"Lang": lang,
"ErrorMessage": errorMessage,
}
// Render the error template
// Return 200 OK with error content - HTMX 1.9.x logs console.error for non-2xx responses
// Validation errors are expected form feedback, not system errors
w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
w.WriteHeader(http.StatusOK)
tmpl, err := h.templates.Render("contact-error")
if err != nil {
log.Printf("Error loading contact error template: %v", err)
// Fallback to simple HTML
_, _ = w.Write([]byte(`<div class="contact-message contact-error">
<iconify-icon icon="mdi:alert-circle" width="24" height="24"></iconify-icon>
<div class="contact-message-content">
<strong>Error</strong>
<p>` + errorMessage + `</p>
</div>
</div>`))
return
}
if err := tmpl.Execute(w, data); err != nil {
log.Printf("Error rendering contact error template: %v", err)
_, _ = w.Write([]byte(`<div class="contact-message contact-error">
<iconify-icon icon="mdi:alert-circle" width="24" height="24"></iconify-icon>
<div class="contact-message-content">
<strong>Error</strong>
<p>` + errorMessage + `</p>
</div>
</div>`))
}
}
// Note: getClientIP is defined in contact.go