feat: Add plain text CV endpoint and contact form with security
Plain text endpoint: - Add /text route for plain text CV (for curl/AI crawlers) - Use k3a/html2text library for HTML-to-text conversion - Add Plain Text button to hamburger menu with UI translations Contact form feature: - Add ContactHandler with proper email service integration - Add CSRF protection middleware - Add rate limiting (5 submissions/hour per IP) - Add honeypot and timing-based bot protection - Add input validation with detailed error messages - Add security logging middleware - Add browser-only middleware for API protection Code quality: - Fix all golangci-lint errcheck warnings for w.Write calls - Remove duplicate getClientIP functions - Wire up ContactHandler in routes.Setup
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/services"
|
||||
"github.com/juanatsap/cv-site/internal/templates"
|
||||
)
|
||||
|
||||
// ContactHandler handles contact form submissions
|
||||
type ContactHandler struct {
|
||||
templates *templates.Manager
|
||||
emailService *services.EmailService
|
||||
}
|
||||
|
||||
// NewContactHandler creates a new contact handler
|
||||
func NewContactHandler(tmpl *templates.Manager, emailService *services.EmailService) *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 < 2*time.Second {
|
||||
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 := &services.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("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
tmpl, err := h.templates.Render("contact_success.html")
|
||||
if err != nil {
|
||||
log.Printf("ERROR loading success template: %v", err)
|
||||
// Fallback to simple HTML
|
||||
_, _ = w.Write([]byte(`<div class="alert alert-success">
|
||||
<h3>Message Sent!</h3>
|
||||
<p>Thank you for your message. I'll get back to you soon.</p>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, nil); err != nil {
|
||||
log.Printf("ERROR rendering success template: %v", err)
|
||||
_, _ = w.Write([]byte(`<div class="alert alert-success">
|
||||
<h3>Message Sent!</h3>
|
||||
<p>Thank you for your message. I'll get back to you soon.</p>
|
||||
</div>`))
|
||||
}
|
||||
}
|
||||
|
||||
// renderError renders the error partial
|
||||
func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, message string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Message": message,
|
||||
}
|
||||
|
||||
tmpl, err := h.templates.Render("contact_error.html")
|
||||
if err != nil {
|
||||
log.Printf("ERROR loading error template: %v", err)
|
||||
// Fallback to simple HTML
|
||||
_, _ = w.Write([]byte(`<div class="alert alert-error">
|
||||
<h3>Error</h3>
|
||||
<p>` + message + `</p>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Printf("ERROR rendering error template: %v", err)
|
||||
_, _ = w.Write([]byte(`<div class="alert alert-error">
|
||||
<h3>Error</h3>
|
||||
<p>` + message + `</p>
|
||||
</div>`))
|
||||
}
|
||||
}
|
||||
|
||||
// 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("X-Forwarded-For")
|
||||
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("X-Real-IP")
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user