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),
|
WriteTimeout: getEnvAsInt("WRITE_TIMEOUT", 15),
|
||||||
},
|
},
|
||||||
Template: TemplateConfig{
|
Template: TemplateConfig{
|
||||||
Dir: getEnv("TEMPLATE_DIR", "templates"),
|
Dir: getEnv("TEMPLATE_DIR", c.DirTemplates),
|
||||||
PartialsDir: getEnv("PARTIALS_DIR", "templates/partials"),
|
PartialsDir: getEnv("PARTIALS_DIR", c.DirPartials),
|
||||||
HotReload: getEnvAsBool("TEMPLATE_HOT_RELOAD", isDevelopment()),
|
HotReload: getEnvAsBool("TEMPLATE_HOT_RELOAD", isDevelopment()),
|
||||||
},
|
},
|
||||||
Data: DataConfig{
|
Data: DataConfig{
|
||||||
Dir: getEnv("DATA_DIR", "data"),
|
Dir: getEnv("DATA_DIR", c.DirData),
|
||||||
},
|
},
|
||||||
Email: EmailConfig{
|
Email: EmailConfig{
|
||||||
SMTPHost: getEnv("SMTP_HOST", "smtp.gmail.com"),
|
SMTPHost: getEnv("SMTP_HOST", "smtp.gmail.com"),
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// Package constants provides global constants used across the application.
|
// Package constants provides global constants used across the application.
|
||||||
package constants
|
package constants
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
// HTTP CONTENT TYPES
|
// HTTP CONTENT TYPES
|
||||||
@@ -80,6 +83,25 @@ var SupportedLanguages = map[string]bool{
|
|||||||
LangSpanish: true,
|
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
|
// CV PREFERENCES
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
@@ -122,6 +144,20 @@ const (
|
|||||||
const (
|
const (
|
||||||
TimeoutPDFGeneration = 30 * time.Second
|
TimeoutPDFGeneration = 30 * time.Second
|
||||||
TimeoutHTTPRequest = 10 * 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
|
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
|
// SECURITY
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
@@ -168,6 +225,7 @@ const (
|
|||||||
HeaderRetryAfter = "Retry-After"
|
HeaderRetryAfter = "Retry-After"
|
||||||
HeaderXForwardedFor = "X-Forwarded-For"
|
HeaderXForwardedFor = "X-Forwarded-For"
|
||||||
HeaderXRealIP = "X-Real-IP"
|
HeaderXRealIP = "X-Real-IP"
|
||||||
|
HeaderXCSRFToken = "X-CSRF-Token"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
@@ -179,6 +237,13 @@ const (
|
|||||||
HeaderAccept = "Accept"
|
HeaderAccept = "Accept"
|
||||||
HeaderOrigin = "Origin"
|
HeaderOrigin = "Origin"
|
||||||
HeaderReferer = "Referer"
|
HeaderReferer = "Referer"
|
||||||
|
HeaderXRequestedWith = "X-Requested-With"
|
||||||
|
HeaderXBrowserReq = "X-Browser-Request"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Header values
|
||||||
|
const (
|
||||||
|
HeaderValueXMLHTTPRequest = "XMLHttpRequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package services
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailConfig holds SMTP configuration
|
// Config holds SMTP configuration
|
||||||
type EmailConfig struct {
|
type Config struct {
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
SMTPPort string
|
SMTPPort string
|
||||||
SMTPUser string
|
SMTPUser string
|
||||||
@@ -23,14 +23,14 @@ type EmailConfig struct {
|
|||||||
ToEmail string
|
ToEmail string
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailService handles email sending operations
|
// Service handles email sending operations
|
||||||
type EmailService struct {
|
type Service struct {
|
||||||
config *EmailConfig
|
config *Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEmailService creates a new email service
|
// NewService creates a new email service
|
||||||
func NewEmailService(config *EmailConfig) *EmailService {
|
func NewService(config *Config) *Service {
|
||||||
return &EmailService{
|
return &Service{
|
||||||
config: config,
|
config: config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ func containsNewlines(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendContactForm sends a contact form email with HTML and plain text versions
|
// 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
|
// Validate data
|
||||||
if err := data.Validate(); err != nil {
|
if err := data.Validate(); err != nil {
|
||||||
return fmt.Errorf("validation failed: %w", err)
|
return fmt.Errorf("validation failed: %w", err)
|
||||||
@@ -148,7 +148,7 @@ type emailTemplateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildEmailBody creates both HTML and plain text email bodies
|
// 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
|
// Prepare template data with safe defaults
|
||||||
tmplData := emailTemplateData{
|
tmplData := emailTemplateData{
|
||||||
Name: data.Name,
|
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
|
// 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
|
// Validate config
|
||||||
if e.config.SMTPHost == "" || e.config.SMTPPort == "" {
|
if e.config.SMTPHost == "" || e.config.SMTPPort == "" {
|
||||||
return fmt.Errorf("SMTP configuration incomplete")
|
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
|
// 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{
|
tlsConfig := &tls.Config{
|
||||||
ServerName: e.config.SMTPHost,
|
ServerName: e.config.SMTPHost,
|
||||||
MinVersion: tls.VersionTLS12,
|
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
|
// 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
|
// Generate boundary for multipart
|
||||||
boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano())
|
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
|
// CVEmailTheme provides a custom Hermes theme matching the CV's aesthetic
|
||||||
// Features:
|
// Features:
|
||||||
@@ -8,14 +8,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
c "github.com/juanatsap/cv-site/internal/constants"
|
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"
|
"github.com/juanatsap/cv-site/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailSender is an interface for sending contact form emails
|
// EmailSender is an interface for sending contact form emails
|
||||||
// This allows for easy mocking in tests
|
// This allows for easy mocking in tests
|
||||||
type EmailSender interface {
|
type EmailSender interface {
|
||||||
SendContactForm(data *services.ContactFormData) error
|
SendContactForm(data *email.ContactFormData) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContactHandler handles contact form submissions
|
// ContactHandler handles contact form submissions
|
||||||
@@ -88,7 +88,7 @@ func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
elapsed := time.Since(submitTime)
|
elapsed := time.Since(submitTime)
|
||||||
|
|
||||||
// Reject if submitted too fast (< 2 seconds)
|
// 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))
|
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.")
|
h.renderError(w, r, "Please take your time filling out the form.")
|
||||||
return
|
return
|
||||||
@@ -109,7 +109,7 @@ func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create email data
|
// Create email data
|
||||||
emailData := &services.ContactFormData{
|
emailData := &email.ContactFormData{
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Company: req.Company,
|
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
|
// getClientIP extracts the client IP address from the request
|
||||||
func getClientIP(r *http.Request) string {
|
func getClientIP(r *http.Request) string {
|
||||||
// Check X-Forwarded-For header (for proxies)
|
// Check X-Forwarded-For header (for proxies)
|
||||||
ip := r.Header.Get("X-Forwarded-For")
|
ip := r.Header.Get(c.HeaderXForwardedFor)
|
||||||
if ip != "" {
|
if ip != "" {
|
||||||
// X-Forwarded-For can contain multiple IPs, take the first one
|
// X-Forwarded-For can contain multiple IPs, take the first one
|
||||||
ips := strings.Split(ip, ",")
|
ips := strings.Split(ip, ",")
|
||||||
@@ -213,7 +213,7 @@ func getClientIP(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check X-Real-IP header
|
// Check X-Real-IP header
|
||||||
ip = r.Header.Get("X-Real-IP")
|
ip = r.Header.Get(c.HeaderXRealIP)
|
||||||
if ip != "" {
|
if ip != "" {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/juanatsap/cv-site/internal/cache"
|
"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/pdf"
|
||||||
"github.com/juanatsap/cv-site/internal/services"
|
"github.com/juanatsap/cv-site/internal/email"
|
||||||
"github.com/juanatsap/cv-site/internal/templates"
|
"github.com/juanatsap/cv-site/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,16 +18,16 @@ import (
|
|||||||
type CVHandler struct {
|
type CVHandler struct {
|
||||||
templates *templates.Manager
|
templates *templates.Manager
|
||||||
pdfGenerator *pdf.Generator
|
pdfGenerator *pdf.Generator
|
||||||
emailService *services.EmailService
|
emailService *email.Service
|
||||||
serverAddr string
|
serverAddr string
|
||||||
dataCache *cache.DataCache
|
dataCache *cache.DataCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCVHandler creates a new CV handler
|
// 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{
|
return &CVHandler{
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
pdfGenerator: pdf.NewGenerator(30 * time.Second),
|
pdfGenerator: pdf.NewGenerator(c.TimeoutPDFGeneration),
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
serverAddr: serverAddr,
|
serverAddr: serverAddr,
|
||||||
dataCache: dataCache,
|
dataCache: dataCache,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
c "github.com/juanatsap/cv-site/internal/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCmdKData tests the CmdKData handler
|
// TestCmdKData tests the CmdKData handler
|
||||||
@@ -93,9 +95,9 @@ func TestCmdKData(t *testing.T) {
|
|||||||
// If success, validate JSON response
|
// If success, validate JSON response
|
||||||
if rec.Code == http.StatusOK {
|
if rec.Code == http.StatusOK {
|
||||||
// Check content type
|
// Check content type
|
||||||
contentType := rec.Header().Get("Content-Type")
|
contentType := rec.Header().Get(c.HeaderContentType)
|
||||||
if contentType != "application/json" {
|
if contentType != c.ContentTypeJSON {
|
||||||
t.Errorf("Expected Content-Type application/json, got %s", contentType)
|
t.Errorf("Expected Content-Type %s, got %s", c.ContentTypeJSON, contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON response
|
// Parse JSON response
|
||||||
@@ -192,11 +194,11 @@ func TestCmdKDataCaching(t *testing.T) {
|
|||||||
handler.CmdKData(rec, req)
|
handler.CmdKData(rec, req)
|
||||||
|
|
||||||
// Check cache header
|
// Check cache header
|
||||||
cacheControl := rec.Header().Get("Cache-Control")
|
cacheControl := rec.Header().Get(c.HeaderCacheControl)
|
||||||
if cacheControl == "" {
|
if cacheControl == "" {
|
||||||
t.Error("Expected Cache-Control header to be set")
|
t.Error("Expected Cache-Control header to be set")
|
||||||
}
|
}
|
||||||
if cacheControl != "public, max-age=3600" {
|
if cacheControl != c.CachePublic1Hour {
|
||||||
t.Errorf("Expected Cache-Control 'public, max-age=3600', got '%s'", cacheControl)
|
t.Errorf("Expected Cache-Control '%s', got '%s'", c.CachePublic1Hour, cacheControl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
c "github.com/juanatsap/cv-site/internal/constants"
|
c "github.com/juanatsap/cv-site/internal/constants"
|
||||||
"github.com/juanatsap/cv-site/internal/httputil"
|
"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
|
// Send email via EmailService
|
||||||
if h.emailService != nil {
|
if h.emailService != nil {
|
||||||
emailData := &services.ContactFormData{
|
emailData := &email.ContactFormData{
|
||||||
Email: formData.Email,
|
Email: formData.Email,
|
||||||
Name: formData.Name,
|
Name: formData.Name,
|
||||||
Company: formData.Company,
|
Company: formData.Company,
|
||||||
|
|||||||
@@ -9,18 +9,19 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// MockEmailService implements a mock email sender for testing
|
||||||
type MockEmailService struct {
|
type MockEmailService struct {
|
||||||
SendCalled bool
|
SendCalled bool
|
||||||
LastEmailData *services.ContactFormData
|
LastEmailData *email.ContactFormData
|
||||||
ShouldFail bool
|
ShouldFail bool
|
||||||
FailError error
|
FailError error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockEmailService) SendContactForm(data *services.ContactFormData) error {
|
func (m *MockEmailService) SendContactForm(data *email.ContactFormData) error {
|
||||||
m.SendCalled = true
|
m.SendCalled = true
|
||||||
m.LastEmailData = data
|
m.LastEmailData = data
|
||||||
if m.ShouldFail {
|
if m.ShouldFail {
|
||||||
@@ -49,7 +50,7 @@ func TestHandleContact_ValidSubmission(t *testing.T) {
|
|||||||
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
|
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.HandleContact(w, req)
|
handler.HandleContact(w, req)
|
||||||
@@ -103,7 +104,7 @@ func TestHandleContact_MissingFields(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(tt.formData.Encode()))
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.HandleContact(w, req)
|
handler.HandleContact(w, req)
|
||||||
@@ -134,7 +135,7 @@ func TestHandleContact_HoneypotDetection(t *testing.T) {
|
|||||||
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
|
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.HandleContact(w, req)
|
handler.HandleContact(w, req)
|
||||||
@@ -161,7 +162,7 @@ func TestHandleContact_TimingCheck(t *testing.T) {
|
|||||||
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
|
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.HandleContact(w, req)
|
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
|
// This ensures graceful fallback to modular CSS if bundle not built
|
||||||
isProduction := os.Getenv(c.EnvVarGOEnv) == c.EnvProduction
|
isProduction := os.Getenv(c.EnvVarGOEnv) == c.EnvProduction
|
||||||
if isProduction {
|
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) {
|
if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
|
||||||
// Bundle doesn't exist, fall back to modular CSS
|
// Bundle doesn't exist, fall back to modular CSS
|
||||||
isProduction = false
|
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
|
// 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)
|
tmpl, err := template.New("cv-text.txt").Funcs(funcMap).ParseFiles(tmplPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("PlainText: Failed to load template: %v", err)
|
log.Printf("PlainText: Failed to load template: %v", err)
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestPlainText tests the PlainText handler
|
// 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) {
|
func TestPlainText(t *testing.T) {
|
||||||
// Skip if running in short mode (CI) - requires project root
|
// Change to project root for template path resolution
|
||||||
if testing.Short() {
|
cleanup := chDirToProjectRoot(t)
|
||||||
t.Skip("Skipping PlainText test - requires running from project root")
|
defer cleanup()
|
||||||
}
|
|
||||||
|
|
||||||
handler := newTestCVHandler(t, "localhost:8080", nil)
|
handler := newTestCVHandler(t, "localhost:8080", nil)
|
||||||
|
|
||||||
@@ -122,12 +118,10 @@ func TestPlainText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestPlainTextDownloadFilename tests that download filename is correctly formatted
|
// 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) {
|
func TestPlainTextDownloadFilename(t *testing.T) {
|
||||||
// Skip if running in short mode (CI) - requires project root
|
// Change to project root for template path resolution
|
||||||
if testing.Short() {
|
cleanup := chDirToProjectRoot(t)
|
||||||
t.Skip("Skipping PlainTextDownloadFilename test - requires running from project root")
|
defer cleanup()
|
||||||
}
|
|
||||||
|
|
||||||
handler := newTestCVHandler(t, "localhost:8080", nil)
|
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
|
// Determine response based on Accept header
|
||||||
accept := r.Header.Get("Accept")
|
accept := r.Header.Get(c.HeaderAccept)
|
||||||
isJSON := accept == c.ContentTypeJSON
|
isJSON := accept == c.ContentTypeJSON
|
||||||
isHTMX := r.Header.Get(c.HeaderHXRequest) != ""
|
isHTMX := r.Header.Get(c.HeaderHXRequest) != ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,55 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/juanatsap/cv-site/internal/cache"
|
"github.com/juanatsap/cv-site/internal/cache"
|
||||||
"github.com/juanatsap/cv-site/internal/config"
|
"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"
|
"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
|
// testCache is a shared cache instance for all tests
|
||||||
var testCache *cache.DataCache
|
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
|
// 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()
|
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{
|
cfg := &config.TemplateConfig{
|
||||||
Dir: "../../templates",
|
Dir: templatesDir,
|
||||||
PartialsDir: "../../templates/partials",
|
PartialsDir: partialsDir,
|
||||||
HotReload: false,
|
HotReload: false,
|
||||||
}
|
}
|
||||||
tmplManager, err := templates.NewManager(cfg)
|
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"
|
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
|
// BrowserOnly restricts endpoint access to browser requests only
|
||||||
// Blocks curl, Postman, and other HTTP clients
|
// 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 HTMX requests, check HX-Request header
|
||||||
// For fetch/XMLHttpRequest, check X-Requested-With header
|
// For fetch/XMLHttpRequest, check X-Requested-With header
|
||||||
hasHTMXHeader := r.Header.Get(c.HeaderHXRequest) == "true"
|
hasHTMXHeader := r.Header.Get(c.HeaderHXRequest) == "true"
|
||||||
hasXMLHTTPHeader := r.Header.Get(browserHeaderName) == browserHeaderValue
|
hasXMLHTTPHeader := r.Header.Get(c.HeaderXRequestedWith) == c.HeaderValueXMLHTTPRequest
|
||||||
hasCustomBrowserHeader := r.Header.Get("X-Browser-Request") == "true"
|
hasCustomBrowserHeader := r.Header.Get(c.HeaderXBrowserReq) == "true"
|
||||||
|
|
||||||
if !hasHTMXHeader && !hasXMLHTTPHeader && !hasCustomBrowserHeader {
|
if !hasHTMXHeader && !hasXMLHTTPHeader && !hasCustomBrowserHeader {
|
||||||
log.Printf("SECURITY: Blocked request without browser headers from IP %s", getRequestIP(r))
|
log.Printf("SECURITY: Blocked request without browser headers from IP %s", getRequestIP(r))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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>
|
<p>You've submitted too many contact forms. Please wait an hour before trying again.</p>
|
||||||
</div>`))
|
</div>`))
|
||||||
} else {
|
} 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)
|
http.Error(w, "Too many contact form submissions. Please try again in an hour.", http.StatusTooManyRequests)
|
||||||
}
|
}
|
||||||
return
|
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
|
// allow checks if the request is allowed based on rate limit
|
||||||
// Limit: 5 submissions per hour
|
|
||||||
func (rl *ContactRateLimiter) allow(ip string) bool {
|
func (rl *ContactRateLimiter) allow(ip string) bool {
|
||||||
rl.mu.Lock()
|
rl.mu.Lock()
|
||||||
defer rl.mu.Unlock()
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
limit := 5
|
limit := c.RateLimitContactRequests
|
||||||
window := 1 * time.Hour
|
window := c.RateLimitContactWindow
|
||||||
|
|
||||||
entry, exists := rl.clients[ip]
|
entry, exists := rl.clients[ip]
|
||||||
if !exists || now.After(entry.resetTime) {
|
if !exists || now.After(entry.resetTime) {
|
||||||
@@ -105,7 +105,7 @@ func (rl *ContactRateLimiter) allow(ip string) bool {
|
|||||||
|
|
||||||
// cleanup removes expired entries periodically
|
// cleanup removes expired entries periodically
|
||||||
func (rl *ContactRateLimiter) cleanup() {
|
func (rl *ContactRateLimiter) cleanup() {
|
||||||
ticker := time.NewTicker(10 * time.Minute)
|
ticker := time.NewTicker(c.RateLimitCleanupPeriod)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
@@ -127,7 +127,7 @@ func (rl *ContactRateLimiter) GetStats() map[string]interface{} {
|
|||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"total_clients": len(rl.clients),
|
"total_clients": len(rl.clients),
|
||||||
"limit": 5,
|
"limit": c.RateLimitContactRequests,
|
||||||
"window": "1 hour",
|
"window": c.RateLimitContactWindow.String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ import (
|
|||||||
c "github.com/juanatsap/cv-site/internal/constants"
|
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
|
// csrfTokenEntry stores token with expiration
|
||||||
type csrfTokenEntry struct {
|
type csrfTokenEntry struct {
|
||||||
token string
|
token string
|
||||||
@@ -76,7 +69,7 @@ func (csrf *CSRFProtection) Middleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
// generateToken creates a new CSRF token
|
// generateToken creates a new CSRF token
|
||||||
func (csrf *CSRFProtection) generateToken() (string, error) {
|
func (csrf *CSRFProtection) generateToken() (string, error) {
|
||||||
bytes := make([]byte, csrfTokenLength)
|
bytes := make([]byte, c.CSRFTokenLength)
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -87,7 +80,7 @@ func (csrf *CSRFProtection) generateToken() (string, error) {
|
|||||||
csrf.mu.Lock()
|
csrf.mu.Lock()
|
||||||
csrf.tokens[token] = &csrfTokenEntry{
|
csrf.tokens[token] = &csrfTokenEntry{
|
||||||
token: token,
|
token: token,
|
||||||
expiresAt: time.Now().Add(csrfTokenTTL),
|
expiresAt: time.Now().Add(c.CSRFTokenTTL),
|
||||||
}
|
}
|
||||||
csrf.mu.Unlock()
|
csrf.mu.Unlock()
|
||||||
|
|
||||||
@@ -98,7 +91,7 @@ func (csrf *CSRFProtection) generateToken() (string, error) {
|
|||||||
// This should be called when rendering forms
|
// This should be called when rendering forms
|
||||||
func (csrf *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (string, error) {
|
func (csrf *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||||
// Check if token exists in cookie
|
// Check if token exists in cookie
|
||||||
cookie, err := r.Cookie(csrfCookieName)
|
cookie, err := r.Cookie(c.CSRFCookieName)
|
||||||
if err == nil && cookie.Value != "" {
|
if err == nil && cookie.Value != "" {
|
||||||
// Validate existing token
|
// Validate existing token
|
||||||
csrf.mu.RLock()
|
csrf.mu.RLock()
|
||||||
@@ -119,13 +112,13 @@ func (csrf *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (st
|
|||||||
|
|
||||||
// Set cookie
|
// Set cookie
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: csrfCookieName,
|
Name: c.CSRFCookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: r.TLS != nil, // Only set Secure flag if using HTTPS
|
Secure: r.TLS != nil, // Only set Secure flag if using HTTPS
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
MaxAge: int(csrfTokenTTL.Seconds()),
|
MaxAge: int(c.CSRFTokenTTL.Seconds()),
|
||||||
})
|
})
|
||||||
|
|
||||||
return token, nil
|
return token, nil
|
||||||
@@ -138,12 +131,12 @@ func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
|
|||||||
|
|
||||||
// Try form value first
|
// Try form value first
|
||||||
if err := r.ParseForm(); err == nil {
|
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 not in form, try header (for AJAX requests)
|
||||||
if formToken == "" {
|
if formToken == "" {
|
||||||
formToken = r.Header.Get("X-CSRF-Token")
|
formToken = r.Header.Get(c.HeaderXCSRFToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
if formToken == "" {
|
if formToken == "" {
|
||||||
@@ -152,7 +145,7 @@ func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get token from cookie
|
// Get token from cookie
|
||||||
cookie, err := r.Cookie(csrfCookieName)
|
cookie, err := r.Cookie(c.CSRFCookieName)
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
log.Printf("CSRF: No token in cookie")
|
log.Printf("CSRF: No token in cookie")
|
||||||
return false
|
return false
|
||||||
@@ -184,7 +177,7 @@ func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
|
|||||||
|
|
||||||
// cleanup removes expired tokens periodically
|
// cleanup removes expired tokens periodically
|
||||||
func (csrf *CSRFProtection) cleanup() {
|
func (csrf *CSRFProtection) cleanup() {
|
||||||
ticker := time.NewTicker(1 * time.Hour)
|
ticker := time.NewTicker(c.CSRFCleanupPeriod)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ func (rl *RateLimiter) allow(ip string) bool {
|
|||||||
|
|
||||||
// cleanup removes expired entries periodically
|
// cleanup removes expired entries periodically
|
||||||
func (rl *RateLimiter) cleanup() {
|
func (rl *RateLimiter) cleanup() {
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(c.RateLimitGeneralCleanupPeriod)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
c "github.com/juanatsap/cv-site/internal/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CV represents the complete curriculum vitae structure
|
// 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)
|
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)
|
filepath, err := findDataFile(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -286,7 +288,7 @@ func LoadUI(lang string) (*UI, error) {
|
|||||||
return nil, fmt.Errorf("unsupported language: %s", lang)
|
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)
|
filepath, err := findDataFile(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
c "github.com/juanatsap/cv-site/internal/constants"
|
||||||
"github.com/juanatsap/cv-site/internal/fileutil"
|
"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
|
// LoadCV loads CV data from a JSON file for the specified language
|
||||||
func LoadCV(language string) (*CV, error) {
|
func LoadCV(language string) (*CV, error) {
|
||||||
if err := lang.Validate(language); err != nil {
|
if err := c.ValidateLang(language); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var cvData CV
|
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 {
|
if err := fileutil.LoadJSON(filename, &cvData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
c "github.com/juanatsap/cv-site/internal/constants"
|
||||||
"github.com/juanatsap/cv-site/internal/fileutil"
|
"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
|
// LoadUI loads UI translations from a JSON file for the specified language
|
||||||
func LoadUI(language string) (*UI, error) {
|
func LoadUI(language string) (*UI, error) {
|
||||||
if err := lang.Validate(language); err != nil {
|
if err := c.ValidateLang(language); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var uiData UI
|
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 {
|
if err := fileutil.LoadJSON(filename, &uiData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
|
|
||||||
|
c "github.com/juanatsap/cv-site/internal/constants"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generator handles PDF generation using headless Chrome
|
// Generator handles PDF generation using headless Chrome
|
||||||
@@ -19,7 +21,7 @@ type Generator struct {
|
|||||||
// NewGenerator creates a new PDF generator with the specified timeout
|
// NewGenerator creates a new PDF generator with the specified timeout
|
||||||
func NewGenerator(timeout time.Duration) *Generator {
|
func NewGenerator(timeout time.Duration) *Generator {
|
||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
timeout = 30 * time.Second
|
timeout = c.TimeoutPDFGeneration
|
||||||
}
|
}
|
||||||
return &Generator{
|
return &Generator{
|
||||||
timeout: timeout,
|
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)
|
// Check if this is a short version (to apply compact sidebar fonts)
|
||||||
// The length parameter is passed as a cookie, not in the URL
|
// 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
|
// Inject CSS to show sidebars AND restore their positioning
|
||||||
tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error {
|
tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package routes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
c "github.com/juanatsap/cv-site/internal/constants"
|
||||||
"github.com/juanatsap/cv-site/internal/handlers"
|
"github.com/juanatsap/cv-site/internal/handlers"
|
||||||
"github.com/juanatsap/cv-site/internal/middleware"
|
"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:
|
// Contact form endpoint with full security chain:
|
||||||
// BrowserOnly → RateLimiter → Handler
|
// BrowserOnly → RateLimiter → Handler
|
||||||
// This blocks curl/Postman, enforces rate limits, then processes the request
|
// 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(
|
protectedContactHandler := middleware.BrowserOnly(
|
||||||
contactRateLimiter.Middleware(
|
contactRateLimiter.Middleware(
|
||||||
http.HandlerFunc(cvHandler.HandleContact),
|
http.HandlerFunc(cvHandler.HandleContact),
|
||||||
@@ -43,7 +43,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
|
|||||||
mux.Handle("/api/contact", protectedContactHandler)
|
mux.Handle("/api/contact", protectedContactHandler)
|
||||||
|
|
||||||
// Protected PDF endpoint with rate limiting (3 requests/minute per IP)
|
// 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(
|
protectedPDFHandler := middleware.OriginChecker(
|
||||||
pdfRateLimiter.Middleware(
|
pdfRateLimiter.Middleware(
|
||||||
http.HandlerFunc(cvHandler.ExportPDF),
|
http.HandlerFunc(cvHandler.ExportPDF),
|
||||||
@@ -52,7 +52,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
|
|||||||
mux.Handle("/export/pdf", protectedPDFHandler)
|
mux.Handle("/export/pdf", protectedPDFHandler)
|
||||||
|
|
||||||
// Static files with cache control
|
// 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))
|
mux.Handle("/static/", middleware.CacheControl(staticHandler))
|
||||||
|
|
||||||
// Apply comprehensive middleware chain
|
// Apply comprehensive middleware chain
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/juanatsap/cv-site/internal/cache"
|
"github.com/juanatsap/cv-site/internal/cache"
|
||||||
"github.com/juanatsap/cv-site/internal/config"
|
"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/handlers"
|
||||||
"github.com/juanatsap/cv-site/internal/routes"
|
"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"
|
"github.com/juanatsap/cv-site/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ func main() {
|
|||||||
log.Println("📦 Data cache initialized (en, es)")
|
log.Println("📦 Data cache initialized (en, es)")
|
||||||
|
|
||||||
// Initialize email service
|
// Initialize email service
|
||||||
emailService := services.NewEmailService(&services.EmailConfig{
|
emailService := email.NewService(&email.Config{
|
||||||
SMTPHost: cfg.Email.SMTPHost,
|
SMTPHost: cfg.Email.SMTPHost,
|
||||||
SMTPPort: cfg.Email.SMTPPort,
|
SMTPPort: cfg.Email.SMTPPort,
|
||||||
SMTPUser: cfg.Email.SMTPUser,
|
SMTPUser: cfg.Email.SMTPUser,
|
||||||
@@ -74,7 +75,7 @@ func main() {
|
|||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadTimeout: time.Duration(cfg.Server.ReadTimeout) * time.Second,
|
ReadTimeout: time.Duration(cfg.Server.ReadTimeout) * time.Second,
|
||||||
WriteTimeout: time.Duration(cfg.Server.WriteTimeout) * time.Second,
|
WriteTimeout: time.Duration(cfg.Server.WriteTimeout) * time.Second,
|
||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: c.TimeoutIdleConnection,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start server in goroutine
|
// Start server in goroutine
|
||||||
@@ -104,7 +105,7 @@ func main() {
|
|||||||
log.Printf("🛑 Shutdown signal received: %v", sig)
|
log.Printf("🛑 Shutdown signal received: %v", sig)
|
||||||
|
|
||||||
// Create shutdown context with timeout
|
// Create shutdown context with timeout
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), c.TimeoutGracefulShutdown)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Attempt graceful shutdown of HTTP server
|
// Attempt graceful shutdown of HTTP server
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// 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")
|
t.Skip("Skipping email send test: CONTACT_EMAIL not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
config := &services.EmailConfig{
|
config := &email.Config{
|
||||||
SMTPHost: host,
|
SMTPHost: host,
|
||||||
SMTPPort: port,
|
SMTPPort: port,
|
||||||
SMTPUser: user,
|
SMTPUser: user,
|
||||||
@@ -132,9 +132,9 @@ func TestEmailServiceSend(t *testing.T) {
|
|||||||
ToEmail: to,
|
ToEmail: to,
|
||||||
}
|
}
|
||||||
|
|
||||||
emailService := services.NewEmailService(config)
|
emailService := email.NewService(config)
|
||||||
|
|
||||||
testData := &services.ContactFormData{
|
testData := &email.ContactFormData{
|
||||||
Email: "test-sender@example.com",
|
Email: "test-sender@example.com",
|
||||||
Name: "Integration Test",
|
Name: "Integration Test",
|
||||||
Company: "Test Suite",
|
Company: "Test Suite",
|
||||||
@@ -155,7 +155,7 @@ func TestEmailServiceSend(t *testing.T) {
|
|||||||
|
|
||||||
// TestEmailServiceValidation tests that the email service properly validates input
|
// TestEmailServiceValidation tests that the email service properly validates input
|
||||||
func TestEmailServiceValidation(t *testing.T) {
|
func TestEmailServiceValidation(t *testing.T) {
|
||||||
config := &services.EmailConfig{
|
config := &email.Config{
|
||||||
SMTPHost: "smtp.test.com",
|
SMTPHost: "smtp.test.com",
|
||||||
SMTPPort: "465",
|
SMTPPort: "465",
|
||||||
SMTPUser: "test@test.com",
|
SMTPUser: "test@test.com",
|
||||||
@@ -164,17 +164,17 @@ func TestEmailServiceValidation(t *testing.T) {
|
|||||||
ToEmail: "to@test.com",
|
ToEmail: "to@test.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
emailService := services.NewEmailService(config)
|
emailService := email.NewService(config)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data *services.ContactFormData
|
data *email.ContactFormData
|
||||||
wantErr bool
|
wantErr bool
|
||||||
errMsg string
|
errMsg string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid data",
|
name: "valid data",
|
||||||
data: &services.ContactFormData{
|
data: &email.ContactFormData{
|
||||||
Email: "valid@example.com",
|
Email: "valid@example.com",
|
||||||
Name: "Valid User",
|
Name: "Valid User",
|
||||||
Message: "This is a valid message with more than 10 characters.",
|
Message: "This is a valid message with more than 10 characters.",
|
||||||
@@ -184,7 +184,7 @@ func TestEmailServiceValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing email",
|
name: "missing email",
|
||||||
data: &services.ContactFormData{
|
data: &email.ContactFormData{
|
||||||
Name: "No Email",
|
Name: "No Email",
|
||||||
Message: "This is a valid message.",
|
Message: "This is a valid message.",
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
@@ -194,7 +194,7 @@ func TestEmailServiceValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid email format",
|
name: "invalid email format",
|
||||||
data: &services.ContactFormData{
|
data: &email.ContactFormData{
|
||||||
Email: "notanemail",
|
Email: "notanemail",
|
||||||
Name: "Bad Email",
|
Name: "Bad Email",
|
||||||
Message: "This is a valid message.",
|
Message: "This is a valid message.",
|
||||||
@@ -205,7 +205,7 @@ func TestEmailServiceValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing message",
|
name: "missing message",
|
||||||
data: &services.ContactFormData{
|
data: &email.ContactFormData{
|
||||||
Email: "valid@example.com",
|
Email: "valid@example.com",
|
||||||
Name: "No Message",
|
Name: "No Message",
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
@@ -215,7 +215,7 @@ func TestEmailServiceValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "message too short",
|
name: "message too short",
|
||||||
data: &services.ContactFormData{
|
data: &email.ContactFormData{
|
||||||
Email: "valid@example.com",
|
Email: "valid@example.com",
|
||||||
Name: "Short Msg",
|
Name: "Short Msg",
|
||||||
Message: "Hi",
|
Message: "Hi",
|
||||||
@@ -226,7 +226,7 @@ func TestEmailServiceValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "email with newlines (header injection)",
|
name: "email with newlines (header injection)",
|
||||||
data: &services.ContactFormData{
|
data: &email.ContactFormData{
|
||||||
Email: "test@example.com\nBcc: attacker@evil.com",
|
Email: "test@example.com\nBcc: attacker@evil.com",
|
||||||
Name: "Attacker",
|
Name: "Attacker",
|
||||||
Message: "Trying to inject headers.",
|
Message: "Trying to inject headers.",
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ import (
|
|||||||
|
|
||||||
"github.com/juanatsap/cv-site/internal/handlers"
|
"github.com/juanatsap/cv-site/internal/handlers"
|
||||||
"github.com/juanatsap/cv-site/internal/middleware"
|
"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"
|
"github.com/juanatsap/cv-site/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockEmailSender is a mock implementation of handlers.EmailSender for testing
|
// mockEmailSender is a mock implementation of handlers.EmailSender for testing
|
||||||
type mockEmailSender struct{}
|
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
|
// Validate like the real service would
|
||||||
if err := data.Validate(); err != nil {
|
if err := data.Validate(); err != nil {
|
||||||
return fmt.Errorf("validation failed: %w", err)
|
return fmt.Errorf("validation failed: %w", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user