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:
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) != ""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user