Files

596 lines
15 KiB
Markdown
Raw Permalink Normal View History

# Contact Form Quick Start Guide
## TL;DR
All security middleware is implemented and tested. You just need to:
1. Create the contact handler
2. Integrate an email service
3. Add the route
4. Create the HTML form
---
## Step 1: Create Contact Handler
**File:** `internal/handlers/contact.go`
```go
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/juanatsap/cv-site/internal/middleware"
"github.com/juanatsap/cv-site/internal/validation"
)
type ContactHandler struct {
// Add email service here when you choose one
// emailService EmailService
}
func NewContactHandler() *ContactHandler {
return &ContactHandler{}
}
// SendMessage handles contact form submissions
func (h *ContactHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
// 1. Parse JSON request
var req validation.ContactFormRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
middleware.LogSecurityEvent(middleware.EventValidationFailed, r, "Invalid JSON: "+err.Error())
http.Error(w, "Invalid request format", http.StatusBadRequest)
return
}
// 2. Set server timestamp (don't trust client)
req.Timestamp = time.Now().Unix()
// 3. Validate input
if err := validation.ValidateContactForm(&req); err != nil {
middleware.LogSecurityEvent(middleware.EventValidationFailed, r, err.Error())
// Return user-friendly error for HTMX
if r.Header.Get("HX-Request") != "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `<div class="error">%s</div>`, err.Error())
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 4. Sanitize content (removes HTML, normalizes whitespace)
validation.SanitizeContactForm(&req)
// 5. Send email
if err := h.sendEmail(&req); err != nil {
middleware.LogSecurityEvent(middleware.EventEmailSendFailed, r, err.Error())
http.Error(w, "Failed to send message. Please try again later.", http.StatusInternalServerError)
return
}
// 6. Log success
middleware.LogSecurityEvent(middleware.EventContactFormSent, r,
fmt.Sprintf("From: %s <%s>", req.Name, req.Email))
// 7. Return success
if r.Header.Get("HX-Request") != "" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<div class="success">Message sent successfully! We'll get back to you soon.</div>`))
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"message": "Message sent successfully",
})
}
// sendEmail sends the contact form email
// TODO: Choose an email service and implement this
func (h *ContactHandler) sendEmail(req *validation.ContactFormRequest) error {
// OPTION 1: SMTP (using net/smtp)
// return h.sendViaSMTP(req)
// OPTION 2: SendGrid API
// return h.sendViaSendGrid(req)
// OPTION 3: AWS SES
// return h.sendViaAWSSES(req)
// OPTION 4: Mailgun API
// return h.sendViaMailgun(req)
// For now, just log it (replace with actual implementation)
log.Printf("EMAIL: From: %s <%s>, Subject: %s\n%s",
req.Name, req.Email, req.Subject, req.Message)
return nil
}
// Example SMTP implementation
/*
import "net/smtp"
func (h *ContactHandler) sendViaSMTP(req *validation.ContactFormRequest) error {
// Load SMTP config from environment
host := os.Getenv("SMTP_HOST")
port := os.Getenv("SMTP_PORT")
user := os.Getenv("SMTP_USER")
pass := os.Getenv("SMTP_PASS")
from := os.Getenv("SMTP_FROM")
to := os.Getenv("CONTACT_EMAIL")
// Set up authentication
auth := smtp.PlainAuth("", user, pass, host)
// Build email
subject := "Contact Form: " + req.Subject
body := fmt.Sprintf(`From: %s <%s>
Company: %s
%s
---
Sent via contact form on %s
`, req.Name, req.Email, req.Company, req.Message, time.Now().Format("2006-01-02 15:04:05"))
msg := []byte(fmt.Sprintf(`To: %s
From: %s
Reply-To: %s
Subject: %s
Content-Type: text/plain; charset=UTF-8
%s`, to, from, req.Email, subject, body))
// Send email
return smtp.SendMail(host+":"+port, auth, from, []string{to}, msg)
}
*/
```
---
## Step 2: Add Route
**File:** `internal/routes/routes.go`
```go
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
mux := http.NewServeMux()
// ... existing routes ...
// Contact form endpoint - FULLY PROTECTED
contactHandler := handlers.NewContactHandler()
csrf := middleware.NewCSRFProtection()
contactRateLimiter := middleware.NewContactRateLimiter()
protectedContactHandler := middleware.BrowserOnly(
csrf.Middleware(
contactRateLimiter.Middleware(
http.HandlerFunc(contactHandler.SendMessage),
),
),
)
mux.Handle("/api/contact", protectedContactHandler)
// ... rest of middleware chain ...
return handler
}
```
---
## Step 3: Create HTML Form Template
**File:** `templates/contact.html`
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Contact Form</title>
<!-- Include HTMX -->
<script src="/static/htmx/htmx.min.js"></script>
<style>
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.5rem; }
input, textarea { width: 100%; padding: 0.5rem; }
button { padding: 0.75rem 1.5rem; background: #0066cc; color: white; border: none; cursor: pointer; }
.error { color: red; padding: 1rem; background: #ffeeee; margin: 1rem 0; }
.success { color: green; padding: 1rem; background: #eeffee; margin: 1rem 0; }
.hidden { position: absolute; left: -9999px; }
</style>
</head>
<body>
<h1>Contact Me</h1>
<form id="contact-form"
hx-post="/api/contact"
hx-trigger="submit"
hx-target="#form-result"
hx-swap="innerHTML"
_="on htmx:afterRequest if event.detail.successful reset() me end">
<!-- CSRF Token (get from server) -->
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<!-- Timestamp (set by JavaScript) -->
<input type="hidden" name="timestamp" id="form-timestamp">
<!-- Honeypot field (hidden from humans, visible to bots) -->
<input type="text"
name="website"
id="website"
class="hidden"
tabindex="-1"
autocomplete="off"
aria-hidden="true">
<!-- Real Fields -->
<div class="form-group">
<label for="name">Name *</label>
<input type="text"
name="name"
id="name"
required
maxlength="100"
pattern="[\p{L}\s'\-]+"
title="Name can only contain letters, spaces, hyphens, and apostrophes">
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email"
name="email"
id="email"
required
maxlength="254">
</div>
<div class="form-group">
<label for="company">Company</label>
<input type="text"
name="company"
id="company"
maxlength="100">
</div>
<div class="form-group">
<label for="subject">Subject *</label>
<input type="text"
name="subject"
id="subject"
required
maxlength="200"
pattern="[\p{L}\p{N}\s.,!?'&quot;()\-:;#]+"
title="Subject can only contain letters, numbers, spaces, and basic punctuation">
</div>
<div class="form-group">
<label for="message">Message *</label>
<textarea name="message"
id="message"
required
maxlength="5000"
rows="6"></textarea>
</div>
<button type="submit">Send Message</button>
</form>
<div id="form-result"></div>
<script>
// Set timestamp when form loads (for bot detection)
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('form-timestamp').value = Math.floor(Date.now() / 1000);
});
</script>
</body>
</html>
```
---
## Step 4: Generate CSRF Token in Handler
**File:** `internal/handlers/contact.go` (add page handler)
```go
// ShowContactForm displays the contact form with CSRF token
func (h *ContactHandler) ShowContactForm(w http.ResponseWriter, r *http.Request) {
// Get or generate CSRF token
csrf := middleware.NewCSRFProtection()
token, err := csrf.GetToken(w, r)
if err != nil {
http.Error(w, "Failed to generate CSRF token", http.StatusInternalServerError)
return
}
// Render template with CSRF token
data := map[string]interface{}{
"CSRFToken": token,
}
// Use your template manager to render
// h.templates.Render(w, "contact.html", data)
}
```
**Add route:**
```go
mux.HandleFunc("/contact", contactHandler.ShowContactForm)
```
---
## Step 5: Configure Email Service
### Option 1: DreamHost SMTP (Recommended)
**Environment variables:**
```bash
# DreamHost uses port 465 with SSL (implicit TLS)
SMTP_HOST=smtp.dreamhost.com
SMTP_PORT=465
SMTP_USER=your-email@yourdomain.com
SMTP_PASSWORD=your-email-password
SMTP_FROM_EMAIL=your-email@yourdomain.com
CONTACT_EMAIL=recipient@example.com
```
### Option 2: Gmail SMTP
**Environment variables:**
```bash
# Gmail uses port 587 with STARTTLS
# Requires App Password (enable 2FA first)
# https://myaccount.google.com/apppasswords
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-specific-password
SMTP_FROM_EMAIL=your-email@gmail.com
CONTACT_EMAIL=recipient@example.com
```
### Port Reference
| Port | Protocol | Description |
|------|----------|-------------|
| 465 | SSL/TLS | Implicit TLS - direct encrypted connection |
| 587 | STARTTLS | Plain connection upgraded to TLS |
### Option 3: SendGrid
```bash
SENDGRID_API_KEY=your-api-key
CONTACT_EMAIL=contact@yourdomain.com
```
### Option 4: AWS SES
```bash
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
CONTACT_EMAIL=contact@yourdomain.com
```
---
## Testing Checklist
### 1. Manual Testing
```bash
# Test valid submission (browser required)
# Fill out form on http://localhost:1999/contact
# Test CSRF protection
curl -X POST http://localhost:1999/api/contact \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"test@example.com","subject":"Test","message":"Test"}'
# Expected: 403 Forbidden (missing CSRF token or browser headers)
# Test rate limiting (submit 6 times within an hour)
# Expected: 6th submission returns 429 Too Many Requests
# Test bot detection - honeypot
# Fill the hidden "website" field
# Expected: Validation error
# Test bot detection - timing
# Submit form immediately after page load
# Expected: Validation error
# Test email injection
# Try: name="Test\nBcc: attacker@evil.com"
# Expected: Validation error
```
### 2. Attack Simulations
```bash
# SQL Injection
curl -X POST http://localhost:1999/api/contact \
-H "Origin: http://localhost:1999" \
-H "X-Requested-With: XMLHttpRequest" \
-H "Cookie: csrf_token=..." \
-d '{"name":"Robert\"; DROP TABLE users; --","email":"test@example.com",...}'
# Expected: 400 Bad Request (invalid name format)
# XSS
# Message: "<script>alert('XSS')</script>"
# Expected: HTML escaped in email
# Email Header Injection
# Subject: "Test\nBcc: attacker@evil.com"
# Expected: 400 Bad Request (invalid characters)
```
---
## Security Monitoring
### Check Logs
```bash
# View security events
tail -f /var/log/cv-app/security.log
# Filter by severity
tail -f /var/log/cv-app/security.log | jq 'select(.severity == "HIGH")'
# Count blocked requests
grep "BLOCKED" /var/log/cv-app/security.log | wc -l
# See who's trying to attack
grep "BLOCKED" /var/log/cv-app/security.log | jq -r '.ip' | sort | uniq -c | sort -rn
```
---
## Troubleshooting
### "CSRF validation failed"
- Make sure CSRF token is being generated and included in form
- Check cookie is being set with correct domain
- Verify token in cookie matches token in form
### "Forbidden: Browser access only"
- Ensure Origin or Referer header is present
- Check ALLOWED_ORIGINS environment variable
- Verify X-Requested-With header is set by HTMX
### "Rate limit exceeded"
- Wait 1 hour and try again
- Check if IP is correctly extracted (X-Forwarded-For)
- Verify rate limit configuration (5 per hour)
### "Bot detected"
- Don't fill the honeypot field (id="website")
- Wait at least 2 seconds before submitting
- Ensure timestamp is set correctly
---
## Production Deployment
### 1. Set Environment Variables
```bash
GO_ENV=production
ALLOWED_ORIGINS=juan.andres.morenorub.io
# DreamHost SMTP Configuration
SMTP_HOST=smtp.dreamhost.com
SMTP_PORT=465
SMTP_USER=info@drolosoft.com
SMTP_PASSWORD=your-password
SMTP_FROM_EMAIL=info@drolosoft.com
CONTACT_EMAIL=your-personal-email@example.com
```
### 2. Configure Nginx Rate Limiting
```nginx
# /etc/nginx/sites-available/cv-app
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/h;
location /api/contact {
limit_req zone=contact burst=1 nodelay;
proxy_pass http://127.0.0.1:1999;
# ... other proxy settings ...
}
```
### 3. Set Up Monitoring
```bash
# Configure fail2ban for repeated attacks
# See SECURITY-AUDIT-REPORT.md for details
# Set up log rotation
sudo vi /etc/logrotate.d/cv-app
# Configure alerts (Prometheus/Grafana)
# Monitor rate_limit_violations, csrf_violations, etc.
```
---
---
## Email Templates
The contact form uses a professional HTML email template that matches the CV's aesthetic.
### Features
- **Responsive design** - Works on desktop, tablet, and mobile
- **Light-only color scheme** - Forces consistent rendering across all email clients
- **Bracket aesthetic** - `{ CV Contact }` header matching CV design
- **Green accent color** - `#27ae60` consistent with CV highlights
- **Multipart format** - Includes both HTML and plain text versions
- **Reply-To header** - Automatically set to the sender's email
### Dark Mode Compatibility
The template uses `<meta name="color-scheme" content="light only">` to prevent
email clients (especially Gmail iOS) from unpredictably inverting colors in dark mode.
**Why not support dark mode?**
- Gmail iOS ignores CSS `@media (prefers-color-scheme: dark)` rules
- It applies its own color inversion algorithm that breaks designs
- Using "light only" ensures the email looks identical everywhere
Reference: [How emails react to dark mode](https://www.hteumeuleu.com/2021/emails-react-to-dark-mode/)
### Template Files
- `internal/services/email_theme.go` - CSS theme and HTML template
- `internal/services/email.go` - Email service with multipart support
### Customization
To customize the email template, edit `email_theme.go`:
```go
// Change accent color
color: #27ae60; // Green - change to your brand color
// Change header text
<span class="bracket">{</span> CV Contact <span class="bracket">}</span>
// Modify footer link
<a href="https://your-domain.com" class="email-footer-link">your-domain.com</a>
```
---
## That's It!
All security middleware is already implemented and tested:
- ✅ CSRF protection
- ✅ Origin validation (browser-only)
- ✅ Input validation & sanitization
- ✅ Rate limiting (5/hour)
- ✅ Bot detection (honeypot + timing)
- ✅ Email header injection prevention
- ✅ Security logging
You just need to:
1. Create the contact handler (copy code above)
2. Choose and configure an email service
3. Add the routes
4. Create the HTML form
**Your contact form is now production-ready with comprehensive security controls.**