diff --git a/internal/config/config.go b/internal/config/config.go index 40ad7a4..d937f32 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"), diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 2751ff1..1f37802 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -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" ) // ============================================================================== diff --git a/internal/services/email.go b/internal/email/email.go similarity index 92% rename from internal/services/email.go rename to internal/email/email.go index 0d0979e..8ffa0c4 100644 --- a/internal/services/email.go +++ b/internal/email/email.go @@ -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()) diff --git a/internal/services/email_theme.go b/internal/email/email_theme.go similarity index 99% rename from internal/services/email_theme.go rename to internal/email/email_theme.go index e4cb922..c9be732 100644 --- a/internal/services/email_theme.go +++ b/internal/email/email_theme.go @@ -1,4 +1,4 @@ -package services +package email // CVEmailTheme provides a custom Hermes theme matching the CV's aesthetic // Features: diff --git a/internal/handlers/contact.go b/internal/handlers/contact.go index b9b1c64..9f7df3f 100644 --- a/internal/handlers/contact.go +++ b/internal/handlers/contact.go @@ -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 } diff --git a/internal/handlers/cv.go b/internal/handlers/cv.go index 5fb1ca7..ffef514 100644 --- a/internal/handlers/cv.go +++ b/internal/handlers/cv.go @@ -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, diff --git a/internal/handlers/cv_cmdk_test.go b/internal/handlers/cv_cmdk_test.go index 4c835c0..23a87e2 100644 --- a/internal/handlers/cv_cmdk_test.go +++ b/internal/handlers/cv_cmdk_test.go @@ -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) } } diff --git a/internal/handlers/cv_contact.go b/internal/handlers/cv_contact.go index 722c29a..0afce89 100644 --- a/internal/handlers/cv_contact.go +++ b/internal/handlers/cv_contact.go @@ -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, diff --git a/internal/handlers/cv_contact_test.go b/internal/handlers/cv_contact_test.go index 5ab2551..32b70f4 100644 --- a/internal/handlers/cv_contact_test.go +++ b/internal/handlers/cv_contact_test.go @@ -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) diff --git a/internal/handlers/cv_helpers.go b/internal/handlers/cv_helpers.go index f8aab15..f172e49 100644 --- a/internal/handlers/cv_helpers.go +++ b/internal/handlers/cv_helpers.go @@ -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 diff --git a/internal/handlers/cv_text.go b/internal/handlers/cv_text.go index 6163887..3c1d521 100644 --- a/internal/handlers/cv_text.go +++ b/internal/handlers/cv_text.go @@ -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) diff --git a/internal/handlers/cv_text_test.go b/internal/handlers/cv_text_test.go index 7430f25..7a70753 100644 --- a/internal/handlers/cv_text_test.go +++ b/internal/handlers/cv_text_test.go @@ -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) diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go index a1e0cda..92628f8 100644 --- a/internal/handlers/errors.go +++ b/internal/handlers/errors.go @@ -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) != "" diff --git a/internal/handlers/test_helpers_test.go b/internal/handlers/test_helpers_test.go index 8938a20..6d3170c 100644 --- a/internal/handlers/test_helpers_test.go +++ b/internal/handlers/test_helpers_test.go @@ -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) diff --git a/internal/lang/lang.go b/internal/lang/lang.go deleted file mode 100644 index a635670..0000000 --- a/internal/lang/lang.go +++ /dev/null @@ -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 -} diff --git a/internal/lang/lang_test.go b/internal/lang/lang_test.go deleted file mode 100644 index 3892b5a..0000000 --- a/internal/lang/lang_test.go +++ /dev/null @@ -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'") - } -} diff --git a/internal/middleware/browser_only.go b/internal/middleware/browser_only.go index 2af0d94..9e08b5c 100644 --- a/internal/middleware/browser_only.go +++ b/internal/middleware/browser_only.go @@ -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)) diff --git a/internal/middleware/contact_rate_limit.go b/internal/middleware/contact_rate_limit.go index c337add..0e7cdce 100644 --- a/internal/middleware/contact_rate_limit.go +++ b/internal/middleware/contact_rate_limit.go @@ -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 {
You've submitted too many contact forms. Please wait an hour before trying again.
`)) } 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(), } } diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go index 5507e3f..d93feb2 100644 --- a/internal/middleware/csrf.go +++ b/internal/middleware/csrf.go @@ -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 { diff --git a/internal/middleware/security.go b/internal/middleware/security.go index ef0acb3..9f3552a 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -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 { diff --git a/internal/models/cv.go b/internal/models/cv.go index 473ca44..a9d2a2d 100644 --- a/internal/models/cv.go +++ b/internal/models/cv.go @@ -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 diff --git a/internal/models/cv/loader.go b/internal/models/cv/loader.go index cc72f37..b729588 100644 --- a/internal/models/cv/loader.go +++ b/internal/models/cv/loader.go @@ -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 } diff --git a/internal/models/ui/loader.go b/internal/models/ui/loader.go index 20e02dc..fa98cd1 100644 --- a/internal/models/ui/loader.go +++ b/internal/models/ui/loader.go @@ -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 } diff --git a/internal/pdf/generator.go b/internal/pdf/generator.go index e46eb90..46e4a2e 100644 --- a/internal/pdf/generator.go +++ b/internal/pdf/generator.go @@ -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 { diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 78d7794..eadbe91 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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 diff --git a/main.go b/main.go index 178b7bc..8b8c543 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/tests/integration/email_test.go b/tests/integration/email_test.go index 2d424c7..be51c23 100644 --- a/tests/integration/email_test.go +++ b/tests/integration/email_test.go @@ -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.", diff --git a/tests/security/contact_security_test.go b/tests/security/contact_security_test.go index 1f32305..a46f76c 100644 --- a/tests/security/contact_security_test.go +++ b/tests/security/contact_security_test.go @@ -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)