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)
This commit is contained in:
juanatsap
2025-12-06 17:05:17 +00:00
parent 30ed21ff7a
commit c89b67a06d
28 changed files with 241 additions and 290 deletions
+3 -3
View File
@@ -56,12 +56,12 @@ func Load() *Config {
WriteTimeout: getEnvAsInt("WRITE_TIMEOUT", 15),
},
Template: TemplateConfig{
Dir: getEnv("TEMPLATE_DIR", "templates"),
PartialsDir: getEnv("PARTIALS_DIR", "templates/partials"),
Dir: getEnv("TEMPLATE_DIR", c.DirTemplates),
PartialsDir: getEnv("PARTIALS_DIR", c.DirPartials),
HotReload: getEnvAsBool("TEMPLATE_HOT_RELOAD", isDevelopment()),
},
Data: DataConfig{
Dir: getEnv("DATA_DIR", "data"),
Dir: getEnv("DATA_DIR", c.DirData),
},
Email: EmailConfig{
SMTPHost: getEnv("SMTP_HOST", "smtp.gmail.com"),
+72 -7
View File
@@ -1,7 +1,10 @@
// Package constants provides global constants used across the application.
package constants
import "time"
import (
"fmt"
"time"
)
// ==============================================================================
// HTTP CONTENT TYPES
@@ -80,6 +83,25 @@ var SupportedLanguages = map[string]bool{
LangSpanish: true,
}
// AllLangs returns all supported language codes
func AllLangs() []string {
return []string{LangEnglish, LangSpanish}
}
// IsValidLang checks if a language code is supported
func IsValidLang(lang string) bool {
return SupportedLanguages[lang]
}
// ValidateLang returns an error if the language code is unsupported.
// It provides helpful error messages showing all supported languages.
func ValidateLang(lang string) error {
if !IsValidLang(lang) {
return fmt.Errorf("unsupported language: %s (supported: %v)", lang, AllLangs())
}
return nil
}
// ==============================================================================
// CV PREFERENCES
// ==============================================================================
@@ -120,8 +142,22 @@ const (
// ==============================================================================
const (
TimeoutPDFGeneration = 30 * time.Second
TimeoutHTTPRequest = 10 * time.Second
TimeoutPDFGeneration = 30 * time.Second
TimeoutHTTPRequest = 10 * time.Second
TimeoutIdleConnection = 120 * time.Second
TimeoutGracefulShutdown = 30 * time.Second
FormMinSubmitTime = 2 * time.Second // Min time form must be displayed (bot protection)
)
// ==============================================================================
// DIRECTORIES
// ==============================================================================
const (
DirData = "data"
DirTemplates = "templates"
DirPartials = "templates/partials"
DirStatic = "static"
)
// ==============================================================================
@@ -133,6 +169,27 @@ const (
A4HeightInches = 11.69
)
// ==============================================================================
// CSRF PROTECTION
// ==============================================================================
const (
CSRFTokenLength = 32
CSRFTokenTTL = 24 * time.Hour
CSRFCookieName = "csrf_token"
CSRFFormField = "csrf_token"
CSRFCleanupPeriod = 1 * time.Hour
)
// ==============================================================================
// CLEANUP INTERVALS
// ==============================================================================
const (
RateLimitCleanupPeriod = 10 * time.Minute // For contact rate limiter
RateLimitGeneralCleanupPeriod = 1 * time.Minute // For general rate limiter
)
// ==============================================================================
// SECURITY
// ==============================================================================
@@ -168,6 +225,7 @@ const (
HeaderRetryAfter = "Retry-After"
HeaderXForwardedFor = "X-Forwarded-For"
HeaderXRealIP = "X-Real-IP"
HeaderXCSRFToken = "X-CSRF-Token"
)
// ==============================================================================
@@ -175,10 +233,17 @@ const (
// ==============================================================================
const (
HeaderUserAgent = "User-Agent"
HeaderAccept = "Accept"
HeaderOrigin = "Origin"
HeaderReferer = "Referer"
HeaderUserAgent = "User-Agent"
HeaderAccept = "Accept"
HeaderOrigin = "Origin"
HeaderReferer = "Referer"
HeaderXRequestedWith = "X-Requested-With"
HeaderXBrowserReq = "X-Browser-Request"
)
// Header values
const (
HeaderValueXMLHTTPRequest = "XMLHttpRequest"
)
// ==============================================================================
@@ -1,4 +1,4 @@
package services
package email
import (
"bytes"
@@ -13,8 +13,8 @@ import (
"time"
)
// EmailConfig holds SMTP configuration
type EmailConfig struct {
// Config holds SMTP configuration
type Config struct {
SMTPHost string
SMTPPort string
SMTPUser string
@@ -23,14 +23,14 @@ type EmailConfig struct {
ToEmail string
}
// EmailService handles email sending operations
type EmailService struct {
config *EmailConfig
// Service handles email sending operations
type Service struct {
config *Config
}
// NewEmailService creates a new email service
func NewEmailService(config *EmailConfig) *EmailService {
return &EmailService{
// NewService creates a new email service
func NewService(config *Config) *Service {
return &Service{
config: config,
}
}
@@ -105,7 +105,7 @@ func containsNewlines(s string) bool {
}
// SendContactForm sends a contact form email with HTML and plain text versions
func (e *EmailService) SendContactForm(data *ContactFormData) error {
func (e *Service) SendContactForm(data *ContactFormData) error {
// Validate data
if err := data.Validate(); err != nil {
return fmt.Errorf("validation failed: %w", err)
@@ -148,7 +148,7 @@ type emailTemplateData struct {
}
// buildEmailBody creates both HTML and plain text email bodies
func (e *EmailService) buildEmailBody(data *ContactFormData) (htmlBody, textBody string, err error) {
func (e *Service) buildEmailBody(data *ContactFormData) (htmlBody, textBody string, err error) {
// Prepare template data with safe defaults
tmplData := emailTemplateData{
Name: data.Name,
@@ -192,7 +192,7 @@ func (e *EmailService) buildEmailBody(data *ContactFormData) (htmlBody, textBody
// sendMultipartEmail sends an email with both HTML and plain text parts
func (e *EmailService) sendMultipartEmail(subject, htmlBody, textBody, replyTo string) error {
func (e *Service) sendMultipartEmail(subject, htmlBody, textBody, replyTo string) error {
// Validate config
if e.config.SMTPHost == "" || e.config.SMTPPort == "" {
return fmt.Errorf("SMTP configuration incomplete")
@@ -259,7 +259,7 @@ func (e *EmailService) sendMultipartEmail(subject, htmlBody, textBody, replyTo s
}
// connectSMTP establishes an SMTP connection with TLS
func (e *EmailService) connectSMTP(addr string) (*smtp.Client, error) {
func (e *Service) connectSMTP(addr string) (*smtp.Client, error) {
tlsConfig := &tls.Config{
ServerName: e.config.SMTPHost,
MinVersion: tls.VersionTLS12,
@@ -296,7 +296,7 @@ func (e *EmailService) connectSMTP(addr string) (*smtp.Client, error) {
}
// formatMultipartMessage formats a multipart email with HTML and plain text
func (e *EmailService) formatMultipartMessage(from, to, replyTo, subject, htmlBody, textBody string) string {
func (e *Service) formatMultipartMessage(from, to, replyTo, subject, htmlBody, textBody string) string {
// Generate boundary for multipart
boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano())
@@ -1,4 +1,4 @@
package services
package email
// CVEmailTheme provides a custom Hermes theme matching the CV's aesthetic
// Features:
+6 -6
View File
@@ -8,14 +8,14 @@ import (
"time"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/services"
"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 *services.ContactFormData) error
SendContactForm(data *email.ContactFormData) error
}
// ContactHandler handles contact form submissions
@@ -88,7 +88,7 @@ func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) {
elapsed := time.Since(submitTime)
// Reject if submitted too fast (< 2 seconds)
if elapsed < 2*time.Second {
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
@@ -109,7 +109,7 @@ func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) {
}
// Create email data
emailData := &services.ContactFormData{
emailData := &email.ContactFormData{
Email: req.Email,
Name: req.Name,
Company: req.Company,
@@ -205,7 +205,7 @@ func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, mes
// 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")
ip := r.Header.Get(c.HeaderXForwardedFor)
if ip != "" {
// X-Forwarded-For can contain multiple IPs, take the first one
ips := strings.Split(ip, ",")
@@ -213,7 +213,7 @@ func getClientIP(r *http.Request) string {
}
// Check X-Real-IP header
ip = r.Header.Get("X-Real-IP")
ip = r.Header.Get(c.HeaderXRealIP)
if ip != "" {
return ip
}
+5 -6
View File
@@ -1,11 +1,10 @@
package handlers
import (
"time"
"github.com/juanatsap/cv-site/internal/cache"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/pdf"
"github.com/juanatsap/cv-site/internal/services"
"github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates"
)
@@ -19,16 +18,16 @@ import (
type CVHandler struct {
templates *templates.Manager
pdfGenerator *pdf.Generator
emailService *services.EmailService
emailService *email.Service
serverAddr string
dataCache *cache.DataCache
}
// NewCVHandler creates a new CV handler
func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *services.EmailService, dataCache *cache.DataCache) *CVHandler {
func NewCVHandler(tmpl *templates.Manager, serverAddr string, emailService *email.Service, dataCache *cache.DataCache) *CVHandler {
return &CVHandler{
templates: tmpl,
pdfGenerator: pdf.NewGenerator(30 * time.Second),
pdfGenerator: pdf.NewGenerator(c.TimeoutPDFGeneration),
emailService: emailService,
serverAddr: serverAddr,
dataCache: dataCache,
+8 -6
View File
@@ -5,6 +5,8 @@ import (
"net/http"
"net/http/httptest"
"testing"
c "github.com/juanatsap/cv-site/internal/constants"
)
// TestCmdKData tests the CmdKData handler
@@ -93,9 +95,9 @@ func TestCmdKData(t *testing.T) {
// If success, validate JSON response
if rec.Code == http.StatusOK {
// Check content type
contentType := rec.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", contentType)
contentType := rec.Header().Get(c.HeaderContentType)
if contentType != c.ContentTypeJSON {
t.Errorf("Expected Content-Type %s, got %s", c.ContentTypeJSON, contentType)
}
// Parse JSON response
@@ -192,11 +194,11 @@ func TestCmdKDataCaching(t *testing.T) {
handler.CmdKData(rec, req)
// Check cache header
cacheControl := rec.Header().Get("Cache-Control")
cacheControl := rec.Header().Get(c.HeaderCacheControl)
if cacheControl == "" {
t.Error("Expected Cache-Control header to be set")
}
if cacheControl != "public, max-age=3600" {
t.Errorf("Expected Cache-Control 'public, max-age=3600', got '%s'", cacheControl)
if cacheControl != c.CachePublic1Hour {
t.Errorf("Expected Cache-Control '%s', got '%s'", c.CachePublic1Hour, cacheControl)
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/httputil"
"github.com/juanatsap/cv-site/internal/services"
"github.com/juanatsap/cv-site/internal/email"
)
// ==============================================================================
@@ -80,7 +80,7 @@ func (h *CVHandler) HandleContact(w http.ResponseWriter, r *http.Request) {
// Send email via EmailService
if h.emailService != nil {
emailData := &services.ContactFormData{
emailData := &email.ContactFormData{
Email: formData.Email,
Name: formData.Name,
Company: formData.Company,
+8 -7
View File
@@ -9,18 +9,19 @@ import (
"testing"
"time"
"github.com/juanatsap/cv-site/internal/services"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/email"
)
// MockEmailService implements a mock email sender for testing
type MockEmailService struct {
SendCalled bool
LastEmailData *services.ContactFormData
LastEmailData *email.ContactFormData
ShouldFail bool
FailError error
}
func (m *MockEmailService) SendContactForm(data *services.ContactFormData) error {
func (m *MockEmailService) SendContactForm(data *email.ContactFormData) error {
m.SendCalled = true
m.LastEmailData = data
if m.ShouldFail {
@@ -49,7 +50,7 @@ func TestHandleContact_ValidSubmission(t *testing.T) {
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set(c.HeaderContentType, c.ContentTypeFormURLEnc)
w := httptest.NewRecorder()
handler.HandleContact(w, req)
@@ -103,7 +104,7 @@ func TestHandleContact_MissingFields(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(tt.formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set(c.HeaderContentType, c.ContentTypeFormURLEnc)
w := httptest.NewRecorder()
handler.HandleContact(w, req)
@@ -134,7 +135,7 @@ func TestHandleContact_HoneypotDetection(t *testing.T) {
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set(c.HeaderContentType, c.ContentTypeFormURLEnc)
w := httptest.NewRecorder()
handler.HandleContact(w, req)
@@ -161,7 +162,7 @@ func TestHandleContact_TimingCheck(t *testing.T) {
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set(c.HeaderContentType, c.ContentTypeFormURLEnc)
w := httptest.NewRecorder()
handler.HandleContact(w, req)
+1 -1
View File
@@ -345,7 +345,7 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er
// This ensures graceful fallback to modular CSS if bundle not built
isProduction := os.Getenv(c.EnvVarGOEnv) == c.EnvProduction
if isProduction {
bundlePath := filepath.Join("static", "dist", "bundle.min.css")
bundlePath := filepath.Join(c.DirStatic, "dist", "bundle.min.css")
if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
// Bundle doesn't exist, fall back to modular CSS
isProduction = false
+1 -1
View File
@@ -130,7 +130,7 @@ func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) {
}
// Load and parse the plain text template with custom functions
tmplPath := filepath.Join("templates", "cv-text.txt")
tmplPath := filepath.Join(c.DirTemplates, "cv-text.txt")
tmpl, err := template.New("cv-text.txt").Funcs(funcMap).ParseFiles(tmplPath)
if err != nil {
log.Printf("PlainText: Failed to load template: %v", err)
+6 -12
View File
@@ -8,14 +8,10 @@ import (
)
// TestPlainText tests the PlainText handler
// NOTE: This test requires running from project root due to template path resolution
// Run with: go test ./internal/handlers/ -run TestPlainText -v
// Or skip in CI: go test ./internal/handlers/ -run TestPlainText -short
func TestPlainText(t *testing.T) {
// Skip if running in short mode (CI) - requires project root
if testing.Short() {
t.Skip("Skipping PlainText test - requires running from project root")
}
// Change to project root for template path resolution
cleanup := chDirToProjectRoot(t)
defer cleanup()
handler := newTestCVHandler(t, "localhost:8080", nil)
@@ -122,12 +118,10 @@ func TestPlainText(t *testing.T) {
}
// TestPlainTextDownloadFilename tests that download filename is correctly formatted
// NOTE: This test requires running from project root due to template path resolution
func TestPlainTextDownloadFilename(t *testing.T) {
// Skip if running in short mode (CI) - requires project root
if testing.Short() {
t.Skip("Skipping PlainTextDownloadFilename test - requires running from project root")
}
// Change to project root for template path resolution
cleanup := chDirToProjectRoot(t)
defer cleanup()
handler := newTestCVHandler(t, "localhost:8080", nil)
+1 -1
View File
@@ -63,7 +63,7 @@ func HandleError(w http.ResponseWriter, r *http.Request, err error) {
}
// Determine response based on Accept header
accept := r.Header.Get("Accept")
accept := r.Header.Get(c.HeaderAccept)
isJSON := accept == c.ContentTypeJSON
isHTMX := r.Header.Get(c.HeaderHXRequest) != ""
+56 -4
View File
@@ -1,14 +1,55 @@
package handlers
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/juanatsap/cv-site/internal/cache"
"github.com/juanatsap/cv-site/internal/config"
"github.com/juanatsap/cv-site/internal/services"
"github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates"
)
// chDirToProjectRoot changes the working directory to the project root.
// This is needed for tests that load files using paths relative to project root.
// Returns a cleanup function that restores the original directory.
func chDirToProjectRoot(t testing.TB) func() {
t.Helper()
// Get the directory of this test file
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("Failed to get current file path")
}
// Navigate from internal/handlers to project root (../../)
projectRoot := filepath.Join(filepath.Dir(filename), "..", "..")
projectRoot, err := filepath.Abs(projectRoot)
if err != nil {
t.Fatalf("Failed to get absolute path: %v", err)
}
// Save current directory
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current directory: %v", err)
}
// Change to project root
if err := os.Chdir(projectRoot); err != nil {
t.Fatalf("Failed to change to project root: %v", err)
}
// Return cleanup function
return func() {
if err := os.Chdir(originalDir); err != nil {
t.Errorf("Failed to restore directory: %v", err)
}
}
}
// testCache is a shared cache instance for all tests
var testCache *cache.DataCache
@@ -28,12 +69,23 @@ func getTestCache(t testing.TB) *cache.DataCache {
}
// newTestCVHandler creates a CVHandler for testing with all required dependencies
func newTestCVHandler(t testing.TB, serverAddr string, emailService *services.EmailService) *CVHandler {
// If fromProjectRoot is true, uses paths relative to project root; otherwise uses relative paths from handlers dir
func newTestCVHandler(t testing.TB, serverAddr string, emailService *email.Service) *CVHandler {
t.Helper()
// Determine if we're running from project root by checking if templates/ exists
templatesDir := "templates"
partialsDir := "templates/partials"
// If templates doesn't exist at current dir, try relative path from handlers
if _, err := os.Stat(templatesDir); os.IsNotExist(err) {
templatesDir = "../../templates"
partialsDir = "../../templates/partials"
}
cfg := &config.TemplateConfig{
Dir: "../../templates",
PartialsDir: "../../templates/partials",
Dir: templatesDir,
PartialsDir: partialsDir,
HotReload: false,
}
tmplManager, err := templates.NewManager(cfg)
-34
View File
@@ -1,34 +0,0 @@
package lang
import "fmt"
// Supported language codes
const (
English = "en"
Spanish = "es"
)
// All returns all supported language codes
func All() []string {
return []string{English, Spanish}
}
// IsValid checks if a language code is supported
func IsValid(lang string) bool {
return lang == English || lang == Spanish
}
// Validate returns an error if the language code is unsupported.
// It provides helpful error messages showing all supported languages.
//
// Example:
//
// if err := lang.Validate("fr"); err != nil {
// // err: unsupported language: fr (supported: [en es])
// }
func Validate(lang string) error {
if !IsValid(lang) {
return fmt.Errorf("unsupported language: %s (supported: %v)", lang, All())
}
return nil
}
-121
View File
@@ -1,121 +0,0 @@
package lang_test
import (
"testing"
"github.com/juanatsap/cv-site/internal/lang"
)
func TestIsValid(t *testing.T) {
tests := []struct {
name string
language string
want bool
}{
{
name: "Valid - English",
language: "en",
want: true,
},
{
name: "Valid - Spanish",
language: "es",
want: true,
},
{
name: "Invalid - French",
language: "fr",
want: false,
},
{
name: "Invalid - Empty",
language: "",
want: false,
},
{
name: "Invalid - Uppercase",
language: "EN",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := lang.IsValid(tt.language); got != tt.want {
t.Errorf("IsValid() = %v, want %v", got, tt.want)
}
})
}
}
func TestValidate(t *testing.T) {
tests := []struct {
name string
language string
wantErr bool
}{
{
name: "Valid - English",
language: "en",
wantErr: false,
},
{
name: "Valid - Spanish",
language: "es",
wantErr: false,
},
{
name: "Invalid - French",
language: "fr",
wantErr: true,
},
{
name: "Invalid - Empty",
language: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := lang.Validate(tt.language)
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
if err != nil && tt.wantErr {
// Check that error message includes supported languages
errMsg := err.Error()
if errMsg == "" {
t.Error("Validate() error message is empty")
}
}
})
}
}
func TestAll(t *testing.T) {
all := lang.All()
if len(all) != 2 {
t.Errorf("All() returned %d languages, want 2", len(all))
}
// Check that it contains en and es
hasEN := false
hasES := false
for _, l := range all {
if l == "en" {
hasEN = true
}
if l == "es" {
hasES = true
}
}
if !hasEN {
t.Error("All() missing 'en'")
}
if !hasES {
t.Error("All() missing 'es'")
}
}
+2 -7
View File
@@ -8,11 +8,6 @@ import (
c "github.com/juanatsap/cv-site/internal/constants"
)
const (
// Custom header that browser JavaScript must set
browserHeaderName = "X-Requested-With"
browserHeaderValue = "XMLHttpRequest"
)
// BrowserOnly restricts endpoint access to browser requests only
// Blocks curl, Postman, and other HTTP clients
@@ -44,8 +39,8 @@ func BrowserOnly(next http.Handler) http.Handler {
// For HTMX requests, check HX-Request header
// For fetch/XMLHttpRequest, check X-Requested-With header
hasHTMXHeader := r.Header.Get(c.HeaderHXRequest) == "true"
hasXMLHTTPHeader := r.Header.Get(browserHeaderName) == browserHeaderValue
hasCustomBrowserHeader := r.Header.Get("X-Browser-Request") == "true"
hasXMLHTTPHeader := r.Header.Get(c.HeaderXRequestedWith) == c.HeaderValueXMLHTTPRequest
hasCustomBrowserHeader := r.Header.Get(c.HeaderXBrowserReq) == "true"
if !hasHTMXHeader && !hasXMLHTTPHeader && !hasCustomBrowserHeader {
log.Printf("SECURITY: Blocked request without browser headers from IP %s", getRequestIP(r))
+7 -7
View File
@@ -1,6 +1,7 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"sync"
@@ -65,7 +66,7 @@ func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler {
<p>You've submitted too many contact forms. Please wait an hour before trying again.</p>
</div>`))
} else {
w.Header().Set(c.HeaderRetryAfter, "3600") // 1 hour
w.Header().Set(c.HeaderRetryAfter, fmt.Sprintf("%d", int(c.RateLimitContactWindow.Seconds())))
http.Error(w, "Too many contact form submissions. Please try again in an hour.", http.StatusTooManyRequests)
}
return
@@ -76,14 +77,13 @@ func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler {
}
// allow checks if the request is allowed based on rate limit
// Limit: 5 submissions per hour
func (rl *ContactRateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
limit := 5
window := 1 * time.Hour
limit := c.RateLimitContactRequests
window := c.RateLimitContactWindow
entry, exists := rl.clients[ip]
if !exists || now.After(entry.resetTime) {
@@ -105,7 +105,7 @@ func (rl *ContactRateLimiter) allow(ip string) bool {
// cleanup removes expired entries periodically
func (rl *ContactRateLimiter) cleanup() {
ticker := time.NewTicker(10 * time.Minute)
ticker := time.NewTicker(c.RateLimitCleanupPeriod)
defer ticker.Stop()
for range ticker.C {
@@ -127,7 +127,7 @@ func (rl *ContactRateLimiter) GetStats() map[string]interface{} {
return map[string]interface{}{
"total_clients": len(rl.clients),
"limit": 5,
"window": "1 hour",
"limit": c.RateLimitContactRequests,
"window": c.RateLimitContactWindow.String(),
}
}
+9 -16
View File
@@ -12,13 +12,6 @@ import (
c "github.com/juanatsap/cv-site/internal/constants"
)
const (
csrfTokenLength = 32
csrfCookieName = "csrf_token"
csrfFormField = "csrf_token"
csrfTokenTTL = 24 * time.Hour
)
// csrfTokenEntry stores token with expiration
type csrfTokenEntry struct {
token string
@@ -76,7 +69,7 @@ func (csrf *CSRFProtection) Middleware(next http.Handler) http.Handler {
// generateToken creates a new CSRF token
func (csrf *CSRFProtection) generateToken() (string, error) {
bytes := make([]byte, csrfTokenLength)
bytes := make([]byte, c.CSRFTokenLength)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
@@ -87,7 +80,7 @@ func (csrf *CSRFProtection) generateToken() (string, error) {
csrf.mu.Lock()
csrf.tokens[token] = &csrfTokenEntry{
token: token,
expiresAt: time.Now().Add(csrfTokenTTL),
expiresAt: time.Now().Add(c.CSRFTokenTTL),
}
csrf.mu.Unlock()
@@ -98,7 +91,7 @@ func (csrf *CSRFProtection) generateToken() (string, error) {
// This should be called when rendering forms
func (csrf *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (string, error) {
// Check if token exists in cookie
cookie, err := r.Cookie(csrfCookieName)
cookie, err := r.Cookie(c.CSRFCookieName)
if err == nil && cookie.Value != "" {
// Validate existing token
csrf.mu.RLock()
@@ -119,13 +112,13 @@ func (csrf *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (st
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Name: c.CSRFCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil, // Only set Secure flag if using HTTPS
SameSite: http.SameSiteStrictMode,
MaxAge: int(csrfTokenTTL.Seconds()),
MaxAge: int(c.CSRFTokenTTL.Seconds()),
})
return token, nil
@@ -138,12 +131,12 @@ func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
// Try form value first
if err := r.ParseForm(); err == nil {
formToken = r.FormValue(csrfFormField)
formToken = r.FormValue(c.CSRFFormField)
}
// If not in form, try header (for AJAX requests)
if formToken == "" {
formToken = r.Header.Get("X-CSRF-Token")
formToken = r.Header.Get(c.HeaderXCSRFToken)
}
if formToken == "" {
@@ -152,7 +145,7 @@ func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
}
// Get token from cookie
cookie, err := r.Cookie(csrfCookieName)
cookie, err := r.Cookie(c.CSRFCookieName)
if err != nil || cookie.Value == "" {
log.Printf("CSRF: No token in cookie")
return false
@@ -184,7 +177,7 @@ func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
// cleanup removes expired tokens periodically
func (csrf *CSRFProtection) cleanup() {
ticker := time.NewTicker(1 * time.Hour)
ticker := time.NewTicker(c.CSRFCleanupPeriod)
defer ticker.Stop()
for range ticker.C {
+1 -1
View File
@@ -208,7 +208,7 @@ func (rl *RateLimiter) allow(ip string) bool {
// cleanup removes expired entries periodically
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(1 * time.Minute)
ticker := time.NewTicker(c.RateLimitGeneralCleanupPeriod)
defer ticker.Stop()
for range ticker.C {
+4 -2
View File
@@ -7,6 +7,8 @@ import (
"os"
"strings"
"time"
c "github.com/juanatsap/cv-site/internal/constants"
)
// CV represents the complete curriculum vitae structure
@@ -250,7 +252,7 @@ func LoadCV(lang string) (*CV, error) {
return nil, fmt.Errorf("unsupported language: %s", lang)
}
filename := fmt.Sprintf("data/cv-%s.json", lang)
filename := fmt.Sprintf("%s/cv-%s.json", c.DirData, lang)
filepath, err := findDataFile(filename)
if err != nil {
return nil, err
@@ -286,7 +288,7 @@ func LoadUI(lang string) (*UI, error) {
return nil, fmt.Errorf("unsupported language: %s", lang)
}
filename := fmt.Sprintf("data/ui-%s.json", lang)
filename := fmt.Sprintf("%s/ui-%s.json", c.DirData, lang)
filepath, err := findDataFile(filename)
if err != nil {
return nil, err
+3 -3
View File
@@ -5,18 +5,18 @@ import (
"strings"
"time"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/fileutil"
"github.com/juanatsap/cv-site/internal/lang"
)
// LoadCV loads CV data from a JSON file for the specified language
func LoadCV(language string) (*CV, error) {
if err := lang.Validate(language); err != nil {
if err := c.ValidateLang(language); err != nil {
return nil, err
}
var cvData CV
filename := fmt.Sprintf("data/cv-%s.json", language)
filename := fmt.Sprintf("%s/cv-%s.json", c.DirData, language)
if err := fileutil.LoadJSON(filename, &cvData); err != nil {
return nil, err
}
+3 -3
View File
@@ -3,18 +3,18 @@ package ui
import (
"fmt"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/fileutil"
"github.com/juanatsap/cv-site/internal/lang"
)
// LoadUI loads UI translations from a JSON file for the specified language
func LoadUI(language string) (*UI, error) {
if err := lang.Validate(language); err != nil {
if err := c.ValidateLang(language); err != nil {
return nil, err
}
var uiData UI
filename := fmt.Sprintf("data/ui-%s.json", language)
filename := fmt.Sprintf("%s/ui-%s.json", c.DirData, language)
if err := fileutil.LoadJSON(filename, &uiData); err != nil {
return nil, err
}
+4 -2
View File
@@ -9,6 +9,8 @@ import (
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
c "github.com/juanatsap/cv-site/internal/constants"
)
// Generator handles PDF generation using headless Chrome
@@ -19,7 +21,7 @@ type Generator struct {
// NewGenerator creates a new PDF generator with the specified timeout
func NewGenerator(timeout time.Duration) *Generator {
if timeout == 0 {
timeout = 30 * time.Second
timeout = c.TimeoutPDFGeneration
}
return &Generator{
timeout: timeout,
@@ -176,7 +178,7 @@ func (g *Generator) GenerateFromURLWithOptions(ctx context.Context, url string,
// Check if this is a short version (to apply compact sidebar fonts)
// The length parameter is passed as a cookie, not in the URL
isShortVersion := cookies["cv-length"] == "short"
isShortVersion := cookies[c.CookieCVLength] == c.CVLengthShort
// Inject CSS to show sidebars AND restore their positioning
tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error {
+4 -4
View File
@@ -2,8 +2,8 @@ package routes
import (
"net/http"
"time"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/handlers"
"github.com/juanatsap/cv-site/internal/middleware"
)
@@ -34,7 +34,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
// Contact form endpoint with full security chain:
// BrowserOnly → RateLimiter → Handler
// This blocks curl/Postman, enforces rate limits, then processes the request
contactRateLimiter := middleware.NewRateLimiter(5, 1*time.Hour)
contactRateLimiter := middleware.NewRateLimiter(c.RateLimitContactRequests, c.RateLimitContactWindow)
protectedContactHandler := middleware.BrowserOnly(
contactRateLimiter.Middleware(
http.HandlerFunc(cvHandler.HandleContact),
@@ -43,7 +43,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
mux.Handle("/api/contact", protectedContactHandler)
// Protected PDF endpoint with rate limiting (3 requests/minute per IP)
pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute)
pdfRateLimiter := middleware.NewRateLimiter(c.RateLimitPDFRequests, c.RateLimitPDFWindow)
protectedPDFHandler := middleware.OriginChecker(
pdfRateLimiter.Middleware(
http.HandlerFunc(cvHandler.ExportPDF),
@@ -52,7 +52,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
mux.Handle("/export/pdf", protectedPDFHandler)
// Static files with cache control
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir(c.DirStatic)))
mux.Handle("/static/", middleware.CacheControl(staticHandler))
// Apply comprehensive middleware chain
+5 -4
View File
@@ -13,9 +13,10 @@ import (
"github.com/joho/godotenv"
"github.com/juanatsap/cv-site/internal/cache"
"github.com/juanatsap/cv-site/internal/config"
c "github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/handlers"
"github.com/juanatsap/cv-site/internal/routes"
"github.com/juanatsap/cv-site/internal/services"
"github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates"
)
@@ -51,7 +52,7 @@ func main() {
log.Println("📦 Data cache initialized (en, es)")
// Initialize email service
emailService := services.NewEmailService(&services.EmailConfig{
emailService := email.NewService(&email.Config{
SMTPHost: cfg.Email.SMTPHost,
SMTPPort: cfg.Email.SMTPPort,
SMTPUser: cfg.Email.SMTPUser,
@@ -74,7 +75,7 @@ func main() {
Handler: handler,
ReadTimeout: time.Duration(cfg.Server.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.Server.WriteTimeout) * time.Second,
IdleTimeout: 120 * time.Second,
IdleTimeout: c.TimeoutIdleConnection,
}
// Start server in goroutine
@@ -104,7 +105,7 @@ func main() {
log.Printf("🛑 Shutdown signal received: %v", sig)
// Create shutdown context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), c.TimeoutGracefulShutdown)
defer cancel()
// Attempt graceful shutdown of HTTP server
+13 -13
View File
@@ -8,7 +8,7 @@ import (
"testing"
"time"
"github.com/juanatsap/cv-site/internal/services"
"github.com/juanatsap/cv-site/internal/email"
)
// TestSMTPConnection tests that SMTP credentials are valid and connection works
@@ -123,7 +123,7 @@ func TestEmailServiceSend(t *testing.T) {
t.Skip("Skipping email send test: CONTACT_EMAIL not configured")
}
config := &services.EmailConfig{
config := &email.Config{
SMTPHost: host,
SMTPPort: port,
SMTPUser: user,
@@ -132,9 +132,9 @@ func TestEmailServiceSend(t *testing.T) {
ToEmail: to,
}
emailService := services.NewEmailService(config)
emailService := email.NewService(config)
testData := &services.ContactFormData{
testData := &email.ContactFormData{
Email: "test-sender@example.com",
Name: "Integration Test",
Company: "Test Suite",
@@ -155,7 +155,7 @@ func TestEmailServiceSend(t *testing.T) {
// TestEmailServiceValidation tests that the email service properly validates input
func TestEmailServiceValidation(t *testing.T) {
config := &services.EmailConfig{
config := &email.Config{
SMTPHost: "smtp.test.com",
SMTPPort: "465",
SMTPUser: "test@test.com",
@@ -164,17 +164,17 @@ func TestEmailServiceValidation(t *testing.T) {
ToEmail: "to@test.com",
}
emailService := services.NewEmailService(config)
emailService := email.NewService(config)
tests := []struct {
name string
data *services.ContactFormData
data *email.ContactFormData
wantErr bool
errMsg string
}{
{
name: "valid data",
data: &services.ContactFormData{
data: &email.ContactFormData{
Email: "valid@example.com",
Name: "Valid User",
Message: "This is a valid message with more than 10 characters.",
@@ -184,7 +184,7 @@ func TestEmailServiceValidation(t *testing.T) {
},
{
name: "missing email",
data: &services.ContactFormData{
data: &email.ContactFormData{
Name: "No Email",
Message: "This is a valid message.",
Time: time.Now(),
@@ -194,7 +194,7 @@ func TestEmailServiceValidation(t *testing.T) {
},
{
name: "invalid email format",
data: &services.ContactFormData{
data: &email.ContactFormData{
Email: "notanemail",
Name: "Bad Email",
Message: "This is a valid message.",
@@ -205,7 +205,7 @@ func TestEmailServiceValidation(t *testing.T) {
},
{
name: "missing message",
data: &services.ContactFormData{
data: &email.ContactFormData{
Email: "valid@example.com",
Name: "No Message",
Time: time.Now(),
@@ -215,7 +215,7 @@ func TestEmailServiceValidation(t *testing.T) {
},
{
name: "message too short",
data: &services.ContactFormData{
data: &email.ContactFormData{
Email: "valid@example.com",
Name: "Short Msg",
Message: "Hi",
@@ -226,7 +226,7 @@ func TestEmailServiceValidation(t *testing.T) {
},
{
name: "email with newlines (header injection)",
data: &services.ContactFormData{
data: &email.ContactFormData{
Email: "test@example.com\nBcc: attacker@evil.com",
Name: "Attacker",
Message: "Trying to inject headers.",
+2 -2
View File
@@ -11,14 +11,14 @@ import (
"github.com/juanatsap/cv-site/internal/handlers"
"github.com/juanatsap/cv-site/internal/middleware"
"github.com/juanatsap/cv-site/internal/services"
"github.com/juanatsap/cv-site/internal/email"
"github.com/juanatsap/cv-site/internal/templates"
)
// mockEmailSender is a mock implementation of handlers.EmailSender for testing
type mockEmailSender struct{}
func (m *mockEmailSender) SendContactForm(data *services.ContactFormData) error {
func (m *mockEmailSender) SendContactForm(data *email.ContactFormData) error {
// Validate like the real service would
if err := data.Validate(); err != nil {
return fmt.Errorf("validation failed: %w", err)