9a848e8c53
Implement a command palette accessible via CMD+K/Ctrl+K using the ninja-keys web component. Features include: - New /api/cmd-k endpoint serving dynamic CV entries (experiences, projects, courses) - Language-aware responses with 1-hour cache headers - Scroll-to-section functionality for quick navigation - Enhanced keyboard shortcuts modal with CMD+K documentation - Comprehensive test coverage for API and UI interactions Also includes cleanup of deprecated debug test files and various UI polish improvements to contact form, themes, and action bar components.
256 lines
7.5 KiB
Go
256 lines
7.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
uimodel "github.com/juanatsap/cv-site/internal/models/ui"
|
|
)
|
|
|
|
// ==============================================================================
|
|
// 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) {
|
|
// Only accept POST requests
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
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
|
|
}
|
|
|
|
// Get language from query parameter
|
|
lang := r.URL.Query().Get("lang")
|
|
if lang == "" {
|
|
lang = "en"
|
|
}
|
|
|
|
// Validate language
|
|
if lang != "en" && lang != "es" {
|
|
lang = "en"
|
|
}
|
|
|
|
// 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 (in production, send email or save to database)
|
|
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))
|
|
|
|
// TODO: Implement actual email sending or database storage here
|
|
// For now, we just log and return success
|
|
|
|
// 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) {
|
|
// Load UI data for the specified language
|
|
ui, err := uimodel.LoadUI(lang)
|
|
if err != nil {
|
|
log.Printf("Error loading UI data: %v", err)
|
|
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("Content-Type", "text/html; charset=utf-8")
|
|
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) {
|
|
// Get language from query parameter
|
|
lang := r.URL.Query().Get("lang")
|
|
if lang == "" {
|
|
lang = "en"
|
|
}
|
|
|
|
// Validate language
|
|
if lang != "en" && lang != "es" {
|
|
lang = "en"
|
|
}
|
|
|
|
// Load UI data for the specified language
|
|
ui, err := uimodel.LoadUI(lang)
|
|
if err != nil {
|
|
log.Printf("Error loading UI data: %v", err)
|
|
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("Content-Type", "text/html; charset=utf-8")
|
|
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
|