refactor: centralize constants and reorganize documentation

- Create internal/constants package with all hardcoded values
  (environment, cookies, themes, headers, routes, cache)
- Create internal/httputil package for HTTP helper functions
- Update all handlers and middleware to use centralized constants
- Reorganize documentation with numbered prefixes (00-26)
- Remove duplicate docs from validation folder and docs/
- Delete handlers/constants.go (moved to internal/constants)
This commit is contained in:
juanatsap
2025-12-06 16:27:12 +00:00
parent 71d9258c58
commit 2c7f8de242
37 changed files with 732 additions and 343 deletions
@@ -8,21 +8,21 @@ This documentation covers the core Go systems that power the CV site, with a foc
### 📚 Documentation Files ### 📚 Documentation Files
1. **[Go Validation System](go-validation-system.md)** (739 lines) 1. **[Go Validation System](24-GO-VALIDATION-SYSTEM.md)** (739 lines)
- Tag-based validation with reflection caching - Tag-based validation with reflection caching
- Built-in validation rules (required, email, pattern, etc.) - Built-in validation rules (required, email, pattern, etc.)
- Security validation (injection prevention, honeypot, timing) - Security validation (injection prevention, honeypot, timing)
- Custom rule extension guide - Custom rule extension guide
- Complete ContactFormRequest example - Complete ContactFormRequest example
2. **[Go Template System](go-template-system.md)** (894 lines) 2. **[Go Template System](25-GO-TEMPLATE-SYSTEM.md)** (894 lines)
- Thread-safe template manager - Thread-safe template manager
- Hot reload mechanism for development - Hot reload mechanism for development
- Custom template functions (iterate, eq, safeHTML, dict) - Custom template functions (iterate, eq, safeHTML, dict)
- Template organization and patterns - Template organization and patterns
- Performance optimizations - Performance optimizations
3. **[Go Routes and API](go-routes-api.md)** (1,203 lines) 3. **[Go Routes and API](26-GO-ROUTES-API.md)** (1,203 lines)
- Complete route table with descriptions - Complete route table with descriptions
- Middleware chain architecture - Middleware chain architecture
- Security features (CSP, HSTS, rate limiting) - Security features (CSP, HSTS, rate limiting)
@@ -34,42 +34,42 @@ This documentation covers the core Go systems that power the CV site, with a foc
### By Feature ### By Feature
**Validation:** **Validation:**
- [Tag Syntax](go-validation-system.md#tag-syntax) - [Tag Syntax](24-GO-VALIDATION-SYSTEM.md#tag-syntax)
- [Available Rules](go-validation-system.md#available-validation-rules) - [Available Rules](24-GO-VALIDATION-SYSTEM.md#available-validation-rules)
- [ContactFormRequest Example](go-validation-system.md#complete-example-contactformrequest) - [ContactFormRequest Example](24-GO-VALIDATION-SYSTEM.md#complete-example-contactformrequest)
- [Error Handling](go-validation-system.md#error-handling) - [Error Handling](24-GO-VALIDATION-SYSTEM.md#error-handling)
- [Security Rules](go-validation-system.md#5-security-validation) - [Security Rules](24-GO-VALIDATION-SYSTEM.md#5-security-validation)
**Templates:** **Templates:**
- [Custom Functions](go-template-system.md#custom-template-functions) - [Custom Functions](25-GO-TEMPLATE-SYSTEM.md#custom-template-functions)
- [Hot Reload](go-template-system.md#hot-reload-mechanism) - [Hot Reload](25-GO-TEMPLATE-SYSTEM.md#hot-reload-mechanism)
- [Thread Safety](go-template-system.md#thread-safety) - [Thread Safety](25-GO-TEMPLATE-SYSTEM.md#thread-safety)
- [Template Patterns](go-template-system.md#template-patterns) - [Template Patterns](25-GO-TEMPLATE-SYSTEM.md#template-patterns)
- [Security Best Practices](go-template-system.md#security-best-practices) - [Security Best Practices](25-GO-TEMPLATE-SYSTEM.md#security-best-practices)
**Routes:** **Routes:**
- [Route Table](go-routes-api.md#route-table) - [Route Table](26-GO-ROUTES-API.md#route-table)
- [Middleware Stack](go-routes-api.md#middleware-stack) - [Middleware Stack](26-GO-ROUTES-API.md#middleware-stack)
- [Contact Form API](go-routes-api.md#apicontact---contact-form-submission) - [Contact Form API](26-GO-ROUTES-API.md#apicontact---contact-form-submission)
- [PDF Export](go-routes-api.md#exportpdf---pdf-export) - [PDF Export](26-GO-ROUTES-API.md#exportpdf---pdf-export)
- [Security Features](go-routes-api.md#security-features) - [Security Features](26-GO-ROUTES-API.md#security-features)
### By Use Case ### By Use Case
**Setting Up Validation:** **Setting Up Validation:**
1. [Define struct with tags](go-validation-system.md#struct-definition) 1. [Define struct with tags](24-GO-VALIDATION-SYSTEM.md#struct-definition)
2. [Call validator](go-validation-system.md#validation-execution) 2. [Call validator](24-GO-VALIDATION-SYSTEM.md#validation-execution)
3. [Handle errors](go-validation-system.md#error-handling-example) 3. [Handle errors](24-GO-VALIDATION-SYSTEM.md#error-handling-example)
**Creating Templates:** **Creating Templates:**
1. [Initialize manager](go-template-system.md#initialization) 1. [Initialize manager](25-GO-TEMPLATE-SYSTEM.md#initialization)
2. [Use custom functions](go-template-system.md#custom-template-functions) 2. [Use custom functions](25-GO-TEMPLATE-SYSTEM.md#custom-template-functions)
3. [Render in handlers](go-template-system.md#usage-in-handlers) 3. [Render in handlers](25-GO-TEMPLATE-SYSTEM.md#usage-in-handlers)
**Adding Routes:** **Adding Routes:**
1. [Configure middleware](go-routes-api.md#middleware-stack) 1. [Configure middleware](26-GO-ROUTES-API.md#middleware-stack)
2. [Register handlers](go-routes-api.md#route-table) 2. [Register handlers](26-GO-ROUTES-API.md#route-table)
3. [Apply security](go-routes-api.md#route-specific-middleware) 3. [Apply security](26-GO-ROUTES-API.md#route-specific-middleware)
## System Architecture ## System Architecture
@@ -363,11 +363,11 @@ export PORT=8080
``` ```
cv/ cv/
├── docs/ ├── doc/
│ ├── README.md # This file │ ├── 24-GO-VALIDATION-SYSTEM.md # Validation docs
│ ├── go-validation-system.md # Validation docs │ ├── 25-GO-TEMPLATE-SYSTEM.md # Template docs
│ ├── go-template-system.md # Template docs │ ├── 26-GO-ROUTES-API.md # Routes/API docs
│ └── go-routes-api.md # Routes/API docs │ └── 00-GO-DOCUMENTATION-INDEX.md # This file
├── internal/ ├── internal/
│ ├── validation/ │ ├── validation/
View File
+5 -3
View File
@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"github.com/juanatsap/cv-site/internal/constants"
) )
// Config holds all application configuration // Config holds all application configuration
@@ -48,7 +50,7 @@ type EmailConfig struct {
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Port: getEnv("PORT", "1999"), Port: getEnv(constants.EnvVarPort, constants.DefaultPort),
Host: getEnv("HOST", "localhost"), Host: getEnv("HOST", "localhost"),
ReadTimeout: getEnvAsInt("READ_TIMEOUT", 15), ReadTimeout: getEnvAsInt("READ_TIMEOUT", 15),
WriteTimeout: getEnvAsInt("WRITE_TIMEOUT", 15), WriteTimeout: getEnvAsInt("WRITE_TIMEOUT", 15),
@@ -103,6 +105,6 @@ func getEnvAsBool(key string, defaultValue bool) bool {
} }
func isDevelopment() bool { func isDevelopment() bool {
env := getEnv("GO_ENV", "development") env := getEnv(constants.EnvVarGOEnv, constants.EnvDevelopment)
return env == "development" || env == "dev" return env == constants.EnvDevelopment || env == "dev"
} }
+228
View File
@@ -0,0 +1,228 @@
// Package constants provides global constants used across the application.
package constants
import "time"
// ==============================================================================
// HTTP CONTENT TYPES
// ==============================================================================
const (
ContentTypeJSON = "application/json"
ContentTypeHTML = "text/html; charset=utf-8"
ContentTypeHTMLFragment = "text/html" // For HTMX fragments
ContentTypePlainText = "text/plain; charset=utf-8"
ContentTypePDF = "application/pdf"
ContentTypeFormURLEnc = "application/x-www-form-urlencoded"
)
// ==============================================================================
// HTTP HEADERS
// ==============================================================================
const (
HeaderContentType = "Content-Type"
HeaderContentDisposition = "Content-Disposition"
HeaderContentLength = "Content-Length"
HeaderCacheControl = "Cache-Control"
HeaderXContentTypeOpts = "X-Content-Type-Options"
// HTMX headers
HeaderHXRequest = "HX-Request"
HeaderHXTrigger = "HX-Trigger"
)
// ==============================================================================
// CACHE CONTROL VALUES
// ==============================================================================
const (
// CachePublic1Hour is for relatively static content (1 hour)
CachePublic1Hour = "public, max-age=3600"
// CachePublic1Day is for static files in production (1 day)
CachePublic1Day = "public, max-age=86400"
// CachePublic5Min is for dynamic content that can be cached briefly
CachePublic5Min = "public, max-age=300, must-revalidate"
// CacheNoStore prevents caching entirely
CacheNoStore = "no-cache, no-store, must-revalidate"
// CacheStatic is for truly static assets (1 year)
CacheStatic = "public, max-age=31536000, immutable"
)
// Cache durations in seconds
const (
CacheDuration1Hour = 3600
CacheDuration5Min = 300
CacheDuration1Year = 31536000
CacheDuration1Day = 86400
CacheDuration1Week = 604800
CacheDuration1Month = 2592000
)
// ==============================================================================
// LANGUAGE CODES
// ==============================================================================
const (
LangEnglish = "en"
LangSpanish = "es"
LangDefault = LangEnglish
)
// SupportedLanguages is the set of valid language codes
var SupportedLanguages = map[string]bool{
LangEnglish: true,
LangSpanish: true,
}
// ==============================================================================
// CV PREFERENCES
// ==============================================================================
const (
CVLengthShort = "short"
CVLengthLong = "long"
CVIconsShow = "show"
CVIconsHide = "hide"
CVThemeDefault = "default"
CVThemeClean = "clean"
)
// ==============================================================================
// COOKIE SETTINGS
// ==============================================================================
const (
CookieMaxAge = 365 * 24 * 60 * 60 // 1 year in seconds
CookiePath = "/"
)
// ==============================================================================
// RATE LIMITING
// ==============================================================================
const (
RateLimitPDFRequests = 3
RateLimitPDFWindow = 1 * time.Minute
RateLimitGeneralRequests = 100
RateLimitGeneralWindow = 1 * time.Minute
RateLimitContactRequests = 5
RateLimitContactWindow = 1 * time.Hour
)
// ==============================================================================
// TIMEOUTS
// ==============================================================================
const (
TimeoutPDFGeneration = 30 * time.Second
TimeoutHTTPRequest = 10 * time.Second
)
// ==============================================================================
// PDF DIMENSIONS
// ==============================================================================
const (
A4WidthInches = 8.27
A4HeightInches = 11.69
)
// ==============================================================================
// SECURITY
// ==============================================================================
const (
// HSTS max-age (1 year)
HSTSMaxAge = "max-age=31536000; includeSubDomains; preload"
// Content type options
NoSniff = "nosniff"
// Frame options
FrameOptionsSameOrigin = "SAMEORIGIN"
// XSS Protection
XSSProtection = "1; mode=block"
// Referrer Policy
ReferrerPolicy = "strict-origin-when-cross-origin"
)
// ==============================================================================
// SECURITY HEADERS
// ==============================================================================
const (
HeaderXFrameOptions = "X-Frame-Options"
HeaderXXSSProtection = "X-XSS-Protection"
HeaderReferrerPolicy = "Referrer-Policy"
HeaderPermissionsPolicy = "Permissions-Policy"
HeaderCSP = "Content-Security-Policy"
HeaderHSTS = "Strict-Transport-Security"
HeaderRetryAfter = "Retry-After"
HeaderXForwardedFor = "X-Forwarded-For"
HeaderXRealIP = "X-Real-IP"
)
// ==============================================================================
// REQUEST HEADERS
// ==============================================================================
const (
HeaderUserAgent = "User-Agent"
HeaderAccept = "Accept"
HeaderOrigin = "Origin"
HeaderReferer = "Referer"
)
// ==============================================================================
// ENVIRONMENT
// ==============================================================================
const (
EnvProduction = "production"
EnvDevelopment = "development"
EnvVarGOEnv = "GO_ENV"
EnvVarPort = "PORT"
DefaultPort = "1999"
)
// ==============================================================================
// COOKIE NAMES
// ==============================================================================
const (
CookieCVLength = "cv-length"
CookieCVIcons = "cv-icons"
CookieCVLanguage = "cv-language"
CookieCVTheme = "cv-theme"
CookieColorTheme = "color-theme"
)
// ==============================================================================
// COLOR THEMES
// ==============================================================================
const (
ColorThemeLight = "light"
ColorThemeDark = "dark"
)
// ==============================================================================
// ROUTES
// ==============================================================================
const (
RouteHome = "/"
RouteHealth = "/health"
RouteExportPDF = "/export/pdf"
RouteAPIContact = "/api/contact"
RouteAPICmdK = "/api/cmd-k"
)
-123
View File
@@ -1,123 +0,0 @@
package handlers
import "time"
// ==============================================================================
// HTTP CONTENT TYPES
// ==============================================================================
const (
// ContentTypePDF is the MIME type for PDF documents
ContentTypePDF = "application/pdf"
// ContentTypeHTML is the MIME type for HTML documents
ContentTypeHTML = "text/html; charset=utf-8"
// ContentTypeJSON is the MIME type for JSON documents
ContentTypeJSON = "application/json"
// ContentTypePlainText is the MIME type for plain text
ContentTypePlainText = "text/plain; charset=utf-8"
)
// ==============================================================================
// RATE LIMITING
// ==============================================================================
const (
// PDFRateLimitRequests is the maximum number of PDF requests per window
PDFRateLimitRequests = 3
// PDFRateLimitWindow is the time window for PDF rate limiting
PDFRateLimitWindow = 1 * time.Minute
// GeneralRateLimitRequests is the default rate limit for general requests
GeneralRateLimitRequests = 100
// GeneralRateLimitWindow is the time window for general rate limiting
GeneralRateLimitWindow = 1 * time.Minute
)
// ==============================================================================
// PDF GENERATION
// ==============================================================================
const (
// A4WidthInches is the width of A4 paper in inches
A4WidthInches = 8.27
// A4HeightInches is the height of A4 paper in inches
A4HeightInches = 11.69
// PDFGenerationTimeout is the maximum time allowed for PDF generation
PDFGenerationTimeout = 30 * time.Second
)
// ==============================================================================
// COOKIE SETTINGS
// ==============================================================================
const (
// CookieMaxAge is the default cookie expiration (1 year in seconds)
CookieMaxAge = 365 * 24 * 60 * 60
// CookiePath is the default cookie path
CookiePath = "/"
)
// ==============================================================================
// LANGUAGE CODES
// ==============================================================================
const (
// LangEnglish is the English language code
LangEnglish = "en"
// LangSpanish is the Spanish language code
LangSpanish = "es"
// DefaultLanguage is the default language for the application
DefaultLanguage = LangEnglish
)
// ==============================================================================
// CV PREFERENCES
// ==============================================================================
const (
// CVLengthShort represents the short CV format
CVLengthShort = "short"
// CVLengthLong represents the long CV format
CVLengthLong = "long"
// CVIconsShow indicates icons should be visible
CVIconsShow = "show"
// CVIconsHide indicates icons should be hidden
CVIconsHide = "hide"
// CVThemeDefault is the default CV theme
CVThemeDefault = "default"
// CVThemeClean is the clean CV theme
CVThemeClean = "clean"
)
// ==============================================================================
// HTTP HEADERS
// ==============================================================================
const (
// HeaderContentType is the Content-Type header key
HeaderContentType = "Content-Type"
// HeaderContentDisposition is the Content-Disposition header key
HeaderContentDisposition = "Content-Disposition"
// HeaderCacheControl is the Cache-Control header key
HeaderCacheControl = "Cache-Control"
// HeaderHXRequest is the HTMX request header
HeaderHXRequest = "HX-Request"
)
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/services"
"github.com/juanatsap/cv-site/internal/templates" "github.com/juanatsap/cv-site/internal/templates"
) )
@@ -142,7 +143,7 @@ func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) {
// renderSuccess renders the success partial // renderSuccess renders the success partial
func (h *ContactHandler) renderSuccess(w http.ResponseWriter, r *http.Request) { func (h *ContactHandler) renderSuccess(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
// Fallback HTML for when templates aren't available (e.g., in tests) // Fallback HTML for when templates aren't available (e.g., in tests)
@@ -172,7 +173,7 @@ func (h *ContactHandler) renderSuccess(w http.ResponseWriter, r *http.Request) {
// renderError renders the error partial // renderError renders the error partial
func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, message string) { func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML)
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
// Fallback HTML for when templates aren't available (e.g., in tests) // Fallback HTML for when templates aren't available (e.g., in tests)
+6 -10
View File
@@ -4,6 +4,9 @@ import (
"encoding/json" "encoding/json"
"log" "log"
"net/http" "net/http"
"github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/httputil"
) )
// CmdKAction represents a single action for the ninja-keys command palette // CmdKAction represents a single action for the ninja-keys command palette
@@ -25,14 +28,7 @@ type CmdKResponse struct {
// This endpoint provides dynamic entries for experiences, projects, and courses // This endpoint provides dynamic entries for experiences, projects, and courses
// that can be searched via CMD+K // that can be searched via CMD+K
func (h *CVHandler) CmdKData(w http.ResponseWriter, r *http.Request) { func (h *CVHandler) CmdKData(w http.ResponseWriter, r *http.Request) {
// Get language from query parameter, default to "en" lang := httputil.Lang(r)
lang := r.URL.Query().Get("lang")
if lang == "" {
lang = "en"
}
if lang != "en" && lang != "es" {
lang = "en"
}
// Get CV data from cache // Get CV data from cache
cv := h.dataCache.GetCV(lang) cv := h.dataCache.GetCV(lang)
@@ -93,8 +89,8 @@ func (h *CVHandler) CmdKData(w http.ResponseWriter, r *http.Request) {
} }
// Set headers and encode response // Set headers and encode response
w.Header().Set("Content-Type", "application/json") w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON)
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour w.Header().Set(constants.HeaderCacheControl, constants.CachePublic1Hour)
if err := json.NewEncoder(w).Encode(response); err != nil { if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("ERROR encoding CMD+K response: %v", err) log.Printf("ERROR encoding CMD+K response: %v", err)
+7 -25
View File
@@ -8,6 +8,8 @@ import (
"strings" "strings"
"time" "time"
"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/services"
) )
@@ -30,9 +32,7 @@ type ContactFormData struct {
// HandleContact handles contact form submissions // HandleContact handles contact form submissions
func (h *CVHandler) HandleContact(w http.ResponseWriter, r *http.Request) { func (h *CVHandler) HandleContact(w http.ResponseWriter, r *http.Request) {
// Only accept POST requests if !httputil.RequirePost(w, r) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
@@ -43,16 +43,7 @@ func (h *CVHandler) HandleContact(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get language from query parameter lang := httputil.Lang(r)
lang := r.URL.Query().Get("lang")
if lang == "" {
lang = "en"
}
// Validate language
if lang != "en" && lang != "es" {
lang = "en"
}
// Extract form data // Extract form data
formData := &ContactFormData{ formData := &ContactFormData{
@@ -182,7 +173,7 @@ func (h *CVHandler) renderContactSuccess(w http.ResponseWriter, r *http.Request,
} }
// Render the success template // Render the success template
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
tmpl, err := h.templates.Render("contact-success") tmpl, err := h.templates.Render("contact-success")
@@ -213,16 +204,7 @@ func (h *CVHandler) renderContactSuccess(w http.ResponseWriter, r *http.Request,
// renderContactError renders the contact error partial // renderContactError renders the contact error partial
func (h *CVHandler) renderContactError(w http.ResponseWriter, r *http.Request, errorMessage string) { func (h *CVHandler) renderContactError(w http.ResponseWriter, r *http.Request, errorMessage string) {
// Get language from query parameter lang := httputil.Lang(r)
lang := r.URL.Query().Get("lang")
if lang == "" {
lang = "en"
}
// Validate language
if lang != "en" && lang != "es" {
lang = "en"
}
// Get UI data from cache // Get UI data from cache
ui := h.dataCache.GetUI(lang) ui := h.dataCache.GetUI(lang)
@@ -242,7 +224,7 @@ func (h *CVHandler) renderContactError(w http.ResponseWriter, r *http.Request, e
// Render the error template // Render the error template
// Return 200 OK with error content - HTMX 1.9.x logs console.error for non-2xx responses // Return 200 OK with error content - HTMX 1.9.x logs console.error for non-2xx responses
// Validation errors are expected form feedback, not system errors // Validation errors are expected form feedback, not system errors
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
tmpl, err := h.templates.Render("contact-error") tmpl, err := h.templates.Render("contact-error")
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
"github.com/juanatsap/cv-site/internal/constants"
cvmodel "github.com/juanatsap/cv-site/internal/models/cv" cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
) )
@@ -342,7 +343,7 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er
// Check if production mode AND CSS bundle exists // Check if production mode AND CSS bundle exists
// 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("GO_ENV") == "production" isProduction := os.Getenv(constants.EnvVarGOEnv) == constants.EnvProduction
if isProduction { if isProduction {
bundlePath := filepath.Join("static", "dist", "bundle.min.css") bundlePath := filepath.Join("static", "dist", "bundle.min.css")
if _, err := os.Stat(bundlePath); os.IsNotExist(err) { if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
+24 -31
View File
@@ -3,6 +3,8 @@ package handlers
import ( import (
"net/http" "net/http"
"github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/httputil"
"github.com/juanatsap/cv-site/internal/middleware" "github.com/juanatsap/cv-site/internal/middleware"
) )
@@ -14,8 +16,7 @@ import (
// ToggleLength handles CV length toggle (short/long) using atomic out-of-band swaps // ToggleLength handles CV length toggle (short/long) using atomic out-of-band swaps
func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) { func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if !httputil.RequirePost(w, r) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
@@ -24,13 +25,13 @@ func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) {
currentLength := prefs.CVLength currentLength := prefs.CVLength
// Toggle state // Toggle state
newLength := "long" newLength := constants.CVLengthLong
if currentLength == "long" { if currentLength == constants.CVLengthLong {
newLength = "short" newLength = constants.CVLengthShort
} }
// Save new state // Save new state
middleware.SetPreferenceCookie(w, "cv-length", newLength) middleware.SetPreferenceCookie(w, constants.CookieCVLength, newLength)
// Return 204 No Content - frontend uses hx-swap="none" so response body is ignored // Return 204 No Content - frontend uses hx-swap="none" so response body is ignored
// The cookie is set and hyperscript handles the UI state toggle // The cookie is set and hyperscript handles the UI state toggle
@@ -39,8 +40,7 @@ func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) {
// ToggleIcons handles icon visibility toggle using atomic out-of-band swaps // ToggleIcons handles icon visibility toggle using atomic out-of-band swaps
func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) { func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if !httputil.RequirePost(w, r) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
@@ -49,13 +49,13 @@ func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) {
currentIcons := prefs.CVIcons currentIcons := prefs.CVIcons
// Toggle state // Toggle state
newIcons := "hide" newIcons := constants.CVIconsHide
if currentIcons == "hide" { if currentIcons == constants.CVIconsHide {
newIcons = "show" newIcons = constants.CVIconsShow
} }
// Save new state // Save new state
middleware.SetPreferenceCookie(w, "cv-icons", newIcons) middleware.SetPreferenceCookie(w, constants.CookieCVIcons, newIcons)
// Return 204 No Content - frontend uses hx-swap="none" so response body is ignored // Return 204 No Content - frontend uses hx-swap="none" so response body is ignored
// The cookie is set and hyperscript handles the UI state toggle // The cookie is set and hyperscript handles the UI state toggle
@@ -66,20 +66,14 @@ func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) {
// Uses HTMX out-of-band swaps to update both the language selector buttons // Uses HTMX out-of-band swaps to update both the language selector buttons
// and all CV content wrappers in a single response // and all CV content wrappers in a single response
func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) { func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) {
// Get language from query parameter lang, ok := httputil.LangOrError(r)
lang := r.URL.Query().Get("lang") if !ok {
if lang == "" {
lang = "en"
}
// Validate language
if lang != "en" && lang != "es" {
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
return return
} }
// Save language preference // Save language preference
middleware.SetPreferenceCookie(w, "cv-language", lang) middleware.SetPreferenceCookie(w, constants.CookieCVLanguage, lang)
// Prepare template data // Prepare template data
data, err := h.prepareTemplateData(lang) data, err := h.prepareTemplateData(lang)
@@ -92,13 +86,13 @@ func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) {
prefs := middleware.GetPreferences(r) prefs := middleware.GetPreferences(r)
// Add preferences to data // Add preferences to data
if prefs.CVLength == "long" { if prefs.CVLength == constants.CVLengthLong {
data["CVLengthClass"] = "cv-long" data["CVLengthClass"] = "cv-long"
} else { } else {
data["CVLengthClass"] = "cv-short" data["CVLengthClass"] = "cv-short"
} }
data["ShowIcons"] = (prefs.CVIcons == "show") data["ShowIcons"] = (prefs.CVIcons == constants.CVIconsShow)
data["ThemeClean"] = (prefs.CVTheme == "clean") data["ThemeClean"] = (prefs.CVTheme == constants.CVThemeClean)
// Render language-switch template with out-of-band swaps // Render language-switch template with out-of-band swaps
tmpl, err := h.templates.Render("language-switch.html") tmpl, err := h.templates.Render("language-switch.html")
@@ -107,7 +101,7 @@ func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML)
if err := tmpl.Execute(w, data); err != nil { if err := tmpl.Execute(w, data); err != nil {
HandleError(w, r, TemplateError(err, "language-switch.html")) HandleError(w, r, TemplateError(err, "language-switch.html"))
return return
@@ -116,8 +110,7 @@ func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) {
// ToggleTheme handles theme toggle (default/clean) using atomic out-of-band swaps // ToggleTheme handles theme toggle (default/clean) using atomic out-of-band swaps
func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) { func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if !httputil.RequirePost(w, r) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
@@ -126,13 +119,13 @@ func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) {
currentTheme := prefs.CVTheme currentTheme := prefs.CVTheme
// Toggle state // Toggle state
newTheme := "clean" newTheme := constants.CVThemeClean
if currentTheme == "clean" { if currentTheme == constants.CVThemeClean {
newTheme = "default" newTheme = constants.CVThemeDefault
} }
// Save new state // Save new state
middleware.SetPreferenceCookie(w, "cv-theme", newTheme) middleware.SetPreferenceCookie(w, constants.CookieCVTheme, newTheme)
// Return 204 No Content - frontend uses hx-swap="none" so response body is ignored // Return 204 No Content - frontend uses hx-swap="none" so response body is ignored
// The cookie is set and hyperscript handles the UI state toggle // The cookie is set and hyperscript handles the UI state toggle
+17 -27
View File
@@ -8,6 +8,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/httputil"
"github.com/juanatsap/cv-site/internal/middleware" "github.com/juanatsap/cv-site/internal/middleware"
"github.com/juanatsap/cv-site/internal/pdf" "github.com/juanatsap/cv-site/internal/pdf"
) )
@@ -31,14 +33,8 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get language from query parameter, default to English lang, ok := httputil.LangOrError(r)
lang := r.URL.Query().Get("lang") if !ok {
if lang == "" {
lang = "en"
}
// Validate language
if lang != "en" && lang != "es" {
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
return return
} }
@@ -74,7 +70,7 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML)
if err := tmpl.Execute(w, data); err != nil { if err := tmpl.Execute(w, data); err != nil {
HandleError(w, r, TemplateError(err, "index.html")) HandleError(w, r, TemplateError(err, "index.html"))
return return
@@ -83,14 +79,8 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
// CVContent renders just the CV content for HTMX swaps // CVContent renders just the CV content for HTMX swaps
func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) { func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) {
// Get language from query parameter lang, ok := httputil.LangOrError(r)
lang := r.URL.Query().Get("lang") if !ok {
if lang == "" {
lang = "en"
}
// Validate language
if lang != "en" && lang != "es" {
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
return return
} }
@@ -109,7 +99,7 @@ func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML)
if err := tmpl.Execute(w, data); err != nil { if err := tmpl.Execute(w, data); err != nil {
HandleError(w, r, TemplateError(err, "cv-content.html")) HandleError(w, r, TemplateError(err, "cv-content.html"))
return return
@@ -137,7 +127,7 @@ func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) {
lang := strings.TrimSuffix(langWithExt, ".pdf") lang := strings.TrimSuffix(langWithExt, ".pdf")
// Validate language // Validate language
if lang != "en" && lang != "es" { if !constants.SupportedLanguages[lang] {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -157,11 +147,11 @@ func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) {
// Prepare cookies for PDF generation (short, with_skills, light mode) // Prepare cookies for PDF generation (short, with_skills, light mode)
cookies := map[string]string{ cookies := map[string]string{
"cv-length": "short", constants.CookieCVLength: constants.CVLengthShort,
"cv-icons": "show", constants.CookieCVIcons: constants.CVIconsShow,
"cv-language": lang, constants.CookieCVLanguage: lang,
"cv-theme": "default", // with_skills = default theme constants.CookieCVTheme: constants.CVThemeDefault, // with_skills = default theme
"color-theme": "light", // Always light for PDFs constants.CookieColorTheme: constants.ColorThemeLight, // Always light for PDFs
} }
// Construct URL for PDF generation // Construct URL for PDF generation
@@ -180,9 +170,9 @@ func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) {
filename := filepath.Base(path) // cv-jamr-2025-en.pdf filename := filepath.Base(path) // cv-jamr-2025-en.pdf
// Set response headers with shortcut filename // Set response headers with shortcut filename
w.Header().Set("Content-Type", "application/pdf") w.Header().Set(constants.HeaderContentType, constants.ContentTypePDF)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) w.Header().Set(constants.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData))) w.Header().Set(constants.HeaderContentLength, fmt.Sprintf("%d", len(pdfData)))
// Write PDF data // Write PDF data
if _, err := w.Write(pdfData); err != nil { if _, err := w.Write(pdfData); err != nil {
+11 -10
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants"
cvmodel "github.com/juanatsap/cv-site/internal/models/cv" cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
"github.com/juanatsap/cv-site/internal/pdf" "github.com/juanatsap/cv-site/internal/pdf"
) )
@@ -37,21 +38,21 @@ func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) {
// Prepare cookies to set preferences // Prepare cookies to set preferences
cookies := map[string]string{ cookies := map[string]string{
"cv-length": req.Length, constants.CookieCVLength: req.Length,
"cv-icons": req.Icons, constants.CookieCVIcons: req.Icons,
"cv-language": req.Lang, constants.CookieCVLanguage: req.Lang,
} }
// Set theme cookie based on version parameter // Set theme cookie based on version parameter
if req.Version == "clean" { if req.Version == constants.CVThemeClean {
cookies["cv-theme"] = "clean" cookies[constants.CookieCVTheme] = constants.CVThemeClean
} else { } else {
cookies["cv-theme"] = "default" cookies[constants.CookieCVTheme] = constants.CVThemeDefault
} }
// CRITICAL: ALWAYS force light mode for PDF generation (print-friendly) // CRITICAL: ALWAYS force light mode for PDF generation (print-friendly)
// This ensures PDFs are NEVER generated in dark mode, regardless of user's preference // This ensures PDFs are NEVER generated in dark mode, regardless of user's preference
cookies["color-theme"] = "light" cookies[constants.CookieColorTheme] = constants.ColorThemeLight
// Construct URL for PDF generation (navigate to home page) // Construct URL for PDF generation (navigate to home page)
targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, req.Lang) targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, req.Lang)
@@ -110,9 +111,9 @@ func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) {
} }
// Set response headers // Set response headers
w.Header().Set("Content-Type", "application/pdf") w.Header().Set(constants.HeaderContentType, constants.ContentTypePDF)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) w.Header().Set(constants.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData))) w.Header().Set(constants.HeaderContentLength, fmt.Sprintf("%d", len(pdfData)))
// Write PDF data // Write PDF data
if _, err := w.Write(pdfData); err != nil { if _, err := w.Write(pdfData); err != nil {
+7 -10
View File
@@ -10,6 +10,9 @@ import (
"strings" "strings"
"text/template" "text/template"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants"
"github.com/juanatsap/cv-site/internal/httputil"
) )
// Plain text configuration // Plain text configuration
@@ -55,14 +58,8 @@ func isTextBrowser(r *http.Request) bool {
// PlainText renders the CV as plain text // PlainText renders the CV as plain text
// Useful for: curl users, AI crawlers, accessibility, copy-paste // Useful for: curl users, AI crawlers, accessibility, copy-paste
func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) { func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) {
// Get language from query parameter, default to English langCode, ok := httputil.LangOrError(r)
langCode := r.URL.Query().Get("lang") if !ok {
if langCode == "" {
langCode = "en"
}
// Validate language
if langCode != "en" && langCode != "es" {
http.Error(w, "Unsupported language. Use 'en' or 'es'", http.StatusBadRequest) http.Error(w, "Unsupported language. Use 'en' or 'es'", http.StatusBadRequest)
return return
} }
@@ -153,8 +150,8 @@ func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) {
text := cleanPlainText(buf.String()) text := cleanPlainText(buf.String())
// Set response headers // Set response headers
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set(constants.HeaderContentType, constants.ContentTypePlainText)
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set(constants.HeaderXContentTypeOpts, constants.NoSniff)
// Check if download is requested // Check if download is requested
if r.URL.Query().Get("download") == "true" { if r.URL.Query().Get("download") == "true" {
+6 -4
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"github.com/juanatsap/cv-site/internal/constants"
) )
// ErrorResponse represents a structured error response // ErrorResponse represents a structured error response
@@ -62,12 +64,12 @@ 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("Accept")
isJSON := accept == "application/json" isJSON := accept == constants.ContentTypeJSON
isHTMX := r.Header.Get("HX-Request") != "" isHTMX := r.Header.Get(constants.HeaderHXRequest) != ""
if isJSON { if isJSON {
// JSON response // JSON response
w.Header().Set("Content-Type", "application/json") w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON)
w.WriteHeader(appErr.StatusCode) w.WriteHeader(appErr.StatusCode)
response := ErrorResponse{ response := ErrorResponse{
@@ -88,7 +90,7 @@ func HandleError(w http.ResponseWriter, r *http.Request, err error) {
if isHTMX { if isHTMX {
// HTMX response - return simple HTML fragment // HTMX response - return simple HTML fragment
w.Header().Set("Content-Type", "text/html") w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTMLFragment)
w.WriteHeader(appErr.StatusCode) w.WriteHeader(appErr.StatusCode)
message := appErr.Message message := appErr.Message
+3 -1
View File
@@ -5,6 +5,8 @@ import (
"log" "log"
"net/http" "net/http"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants"
) )
// HealthResponse represents the health check response // HealthResponse represents the health check response
@@ -34,7 +36,7 @@ func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
Version: h.version, Version: h.version,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil { if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("ERROR encoding health check response: %v", err) log.Printf("ERROR encoding health check response: %v", err)
+25
View File
@@ -0,0 +1,25 @@
package httputil
import (
"net/http"
)
// RequireMethod checks if the request method matches expected.
// Returns true if method matches, false otherwise (sends 405 error).
func RequireMethod(w http.ResponseWriter, r *http.Request, method string) bool {
if r.Method != method {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return false
}
return true
}
// RequirePost is a shorthand for RequireMethod(w, r, http.MethodPost).
func RequirePost(w http.ResponseWriter, r *http.Request) bool {
return RequireMethod(w, r, http.MethodPost)
}
// RequireGet is a shorthand for RequireMethod(w, r, http.MethodGet).
func RequireGet(w http.ResponseWriter, r *http.Request) bool {
return RequireMethod(w, r, http.MethodGet)
}
+85
View File
@@ -0,0 +1,85 @@
package httputil
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRequireMethod(t *testing.T) {
tests := []struct {
name string
requestMethod string
requiredMethod string
wantOk bool
wantStatus int
}{
{"POST matches POST", http.MethodPost, http.MethodPost, true, 0},
{"GET matches GET", http.MethodGet, http.MethodGet, true, 0},
{"GET doesn't match POST", http.MethodGet, http.MethodPost, false, http.StatusMethodNotAllowed},
{"POST doesn't match GET", http.MethodPost, http.MethodGet, false, http.StatusMethodNotAllowed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.requestMethod, "/", nil)
w := httptest.NewRecorder()
ok := RequireMethod(w, req, tt.requiredMethod)
if ok != tt.wantOk {
t.Errorf("RequireMethod() = %v, want %v", ok, tt.wantOk)
}
if !ok && w.Code != tt.wantStatus {
t.Errorf("RequireMethod() status = %d, want %d", w.Code, tt.wantStatus)
}
})
}
}
func TestRequirePost(t *testing.T) {
t.Run("POST request allowed", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
if !RequirePost(w, req) {
t.Error("RequirePost() should return true for POST request")
}
})
t.Run("GET request rejected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
if RequirePost(w, req) {
t.Error("RequirePost() should return false for GET request")
}
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("RequirePost() status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
})
}
func TestRequireGet(t *testing.T) {
t.Run("GET request allowed", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
if !RequireGet(w, req) {
t.Error("RequireGet() should return true for GET request")
}
})
t.Run("POST request rejected", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
if RequireGet(w, req) {
t.Error("RequireGet() should return false for POST request")
}
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("RequireGet() status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
})
}
+55
View File
@@ -0,0 +1,55 @@
// Package httputil provides simple HTTP request/response utilities.
package httputil
import (
"net/http"
"github.com/juanatsap/cv-site/internal/constants"
)
// Lang extracts and validates the language from query params.
// Returns the language or default if invalid/missing.
func Lang(r *http.Request) string {
lang := r.URL.Query().Get("lang")
if lang == "" || !constants.SupportedLanguages[lang] {
return constants.LangDefault
}
return lang
}
// LangOrError extracts and validates language, returns error if invalid.
// Returns (lang, true) if valid, ("", false) if invalid.
func LangOrError(r *http.Request) (string, bool) {
lang := r.URL.Query().Get("lang")
if lang == "" {
return constants.LangDefault, true
}
if !constants.SupportedLanguages[lang] {
return "", false
}
return lang, true
}
// Query extracts a query parameter with optional default.
func Query(r *http.Request, key, defaultVal string) string {
val := r.URL.Query().Get(key)
if val == "" {
return defaultVal
}
return val
}
// QueryOneOf extracts a query parameter, validates against allowed values.
// Returns the value if valid, or defaultVal if empty/invalid.
func QueryOneOf(r *http.Request, key string, allowed []string, defaultVal string) string {
val := r.URL.Query().Get(key)
if val == "" {
return defaultVal
}
for _, a := range allowed {
if val == a {
return val
}
}
return defaultVal
}
+109
View File
@@ -0,0 +1,109 @@
package httputil
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestLang(t *testing.T) {
tests := []struct {
name string
query string
expected string
}{
{"Empty returns default", "", "en"},
{"English", "?lang=en", "en"},
{"Spanish", "?lang=es", "es"},
{"Invalid returns default", "?lang=fr", "en"},
{"Unknown returns default", "?lang=xyz", "en"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/"+tt.query, nil)
got := Lang(req)
if got != tt.expected {
t.Errorf("Lang() = %q, want %q", got, tt.expected)
}
})
}
}
func TestLangOrError(t *testing.T) {
tests := []struct {
name string
query string
expected string
ok bool
}{
{"Empty returns default", "", "en", true},
{"English", "?lang=en", "en", true},
{"Spanish", "?lang=es", "es", true},
{"Invalid returns error", "?lang=fr", "", false},
{"Unknown returns error", "?lang=xyz", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/"+tt.query, nil)
got, ok := LangOrError(req)
if ok != tt.ok {
t.Errorf("LangOrError() ok = %v, want %v", ok, tt.ok)
}
if got != tt.expected {
t.Errorf("LangOrError() = %q, want %q", got, tt.expected)
}
})
}
}
func TestQuery(t *testing.T) {
tests := []struct {
name string
query string
key string
defaultVal string
expected string
}{
{"Returns value", "?foo=bar", "foo", "default", "bar"},
{"Returns default when missing", "", "foo", "default", "default"},
{"Returns default when empty", "?foo=", "foo", "default", "default"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/"+tt.query, nil)
got := Query(req, tt.key, tt.defaultVal)
if got != tt.expected {
t.Errorf("Query() = %q, want %q", got, tt.expected)
}
})
}
}
func TestQueryOneOf(t *testing.T) {
allowed := []string{"short", "long"}
tests := []struct {
name string
query string
defaultVal string
expected string
}{
{"Returns valid value", "?length=short", "short", "short"},
{"Returns valid value 2", "?length=long", "short", "long"},
{"Returns default when invalid", "?length=medium", "short", "short"},
{"Returns default when empty", "", "short", "short"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/"+tt.query, nil)
got := QueryOneOf(req, "length", allowed, tt.defaultVal)
if got != tt.expected {
t.Errorf("QueryOneOf() = %q, want %q", got, tt.expected)
}
})
}
}
+34
View File
@@ -0,0 +1,34 @@
package httputil
import (
"encoding/json"
"net/http"
)
// JSON writes a JSON response with the given status code.
func JSON(w http.ResponseWriter, status int, data interface{}) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(data)
}
// JSONOk writes a 200 OK JSON response.
func JSONOk(w http.ResponseWriter, data interface{}) error {
return JSON(w, http.StatusOK, data)
}
// JSONCached writes a JSON response with caching headers.
func JSONCached(w http.ResponseWriter, data interface{}, maxAge int) error {
w.Header().Set("Cache-Control", "public, max-age="+string(rune(maxAge)))
return JSONOk(w, data)
}
// HTML sets HTML content type header.
func HTML(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}
// NoContent sends a 204 No Content response.
func NoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
+8 -6
View File
@@ -4,6 +4,8 @@ import (
"log" "log"
"net/http" "net/http"
"strings" "strings"
"github.com/juanatsap/cv-site/internal/constants"
) )
const ( const (
@@ -21,7 +23,7 @@ const (
func BrowserOnly(next http.Handler) http.Handler { func BrowserOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check 1: User-Agent validation // Check 1: User-Agent validation
userAgent := r.Header.Get("User-Agent") userAgent := r.Header.Get(constants.HeaderUserAgent)
if userAgent == "" || isBotUserAgent(userAgent) { if userAgent == "" || isBotUserAgent(userAgent) {
log.Printf("SECURITY: Blocked non-browser User-Agent from IP %s: %s", getRequestIP(r), userAgent) log.Printf("SECURITY: Blocked non-browser User-Agent from IP %s: %s", getRequestIP(r), userAgent)
http.Error(w, "Forbidden: Browser access only", http.StatusForbidden) http.Error(w, "Forbidden: Browser access only", http.StatusForbidden)
@@ -29,8 +31,8 @@ func BrowserOnly(next http.Handler) http.Handler {
} }
// Check 2: Require Referer or Origin header // Check 2: Require Referer or Origin header
referer := r.Header.Get("Referer") referer := r.Header.Get(constants.HeaderReferer)
origin := r.Header.Get("Origin") origin := r.Header.Get(constants.HeaderOrigin)
if referer == "" && origin == "" { if referer == "" && origin == "" {
log.Printf("SECURITY: Blocked request without Referer/Origin from IP %s", getRequestIP(r)) log.Printf("SECURITY: Blocked request without Referer/Origin from IP %s", getRequestIP(r))
@@ -41,7 +43,7 @@ func BrowserOnly(next http.Handler) http.Handler {
// Check 3: Custom header validation (set by JavaScript) // Check 3: Custom header validation (set by JavaScript)
// 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("HX-Request") == "true" hasHTMXHeader := r.Header.Get(constants.HeaderHXRequest) == "true"
hasXMLHTTPHeader := r.Header.Get(browserHeaderName) == browserHeaderValue hasXMLHTTPHeader := r.Header.Get(browserHeaderName) == browserHeaderValue
hasCustomBrowserHeader := r.Header.Get("X-Browser-Request") == "true" hasCustomBrowserHeader := r.Header.Get("X-Browser-Request") == "true"
@@ -96,7 +98,7 @@ func isBotUserAgent(ua string) bool {
// getRequestIP extracts the client IP from the request // getRequestIP extracts the client IP from the request
func getRequestIP(r *http.Request) string { func getRequestIP(r *http.Request) string {
// Try X-Forwarded-For first (for proxies/load balancers) // Try X-Forwarded-For first (for proxies/load balancers)
ip := r.Header.Get("X-Forwarded-For") ip := r.Header.Get(constants.HeaderXForwardedFor)
if ip != "" { if ip != "" {
// Take first IP if multiple // Take first IP if multiple
ips := strings.Split(ip, ",") ips := strings.Split(ip, ",")
@@ -104,7 +106,7 @@ func getRequestIP(r *http.Request) string {
} }
// Try X-Real-IP // Try X-Real-IP
ip = r.Header.Get("X-Real-IP") ip = r.Header.Get(constants.HeaderXRealIP)
if ip != "" { if ip != "" {
return ip return ip
} }
+7 -5
View File
@@ -5,6 +5,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants"
) )
// contactRateLimitEntry tracks rate limiting for contact form per IP // contactRateLimitEntry tracks rate limiting for contact form per IP
@@ -37,9 +39,9 @@ func NewContactRateLimiter() *ContactRateLimiter {
func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler { func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get client IP (handle X-Forwarded-For for proxies) // Get client IP (handle X-Forwarded-For for proxies)
ip := r.Header.Get("X-Forwarded-For") ip := r.Header.Get(constants.HeaderXForwardedFor)
if ip == "" { if ip == "" {
ip = r.Header.Get("X-Real-IP") ip = r.Header.Get(constants.HeaderXRealIP)
} }
if ip == "" { if ip == "" {
ip = strings.Split(r.RemoteAddr, ":")[0] ip = strings.Split(r.RemoteAddr, ":")[0]
@@ -52,18 +54,18 @@ func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler {
if !rl.allow(ip) { if !rl.allow(ip) {
// Check if HTMX request // Check if HTMX request
isHTMX := r.Header.Get("HX-Request") != "" isHTMX := r.Header.Get(constants.HeaderHXRequest) != ""
if isHTMX { if isHTMX {
// Return HTMX-friendly error // Return HTMX-friendly error
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML)
w.WriteHeader(http.StatusTooManyRequests) w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`<div class="alert alert-error"> _, _ = w.Write([]byte(`<div class="alert alert-error">
<h3>Too Many Requests</h3> <h3>Too Many Requests</h3>
<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("Retry-After", "3600") // 1 hour w.Header().Set(constants.HeaderRetryAfter, "3600") // 1 hour
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
+27 -25
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"net/http" "net/http"
"os" "os"
"github.com/juanatsap/cv-site/internal/constants"
) )
// contextKey is a private type for context keys to avoid collisions // contextKey is a private type for context keys to avoid collisions
@@ -28,22 +30,22 @@ type Preferences struct {
func PreferencesMiddleware(next http.Handler) http.Handler { func PreferencesMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
prefs := &Preferences{ prefs := &Preferences{
CVLength: getPreferenceCookie(r, "cv-length", "short"), CVLength: getPreferenceCookie(r, constants.CookieCVLength, constants.CVLengthShort),
CVIcons: getPreferenceCookie(r, "cv-icons", "show"), CVIcons: getPreferenceCookie(r, constants.CookieCVIcons, constants.CVIconsShow),
CVLanguage: getPreferenceCookie(r, "cv-language", "en"), CVLanguage: getPreferenceCookie(r, constants.CookieCVLanguage, constants.LangEnglish),
CVTheme: getPreferenceCookie(r, "cv-theme", "default"), CVTheme: getPreferenceCookie(r, constants.CookieCVTheme, constants.CVThemeDefault),
ColorTheme: getPreferenceCookie(r, "color-theme", "light"), ColorTheme: getPreferenceCookie(r, constants.CookieColorTheme, constants.ColorThemeLight),
} }
// Migrate old preference values (one-time auto-migration) // Migrate old preference values (one-time auto-migration)
if prefs.CVLength == "extended" { if prefs.CVLength == "extended" {
prefs.CVLength = "long" prefs.CVLength = constants.CVLengthLong
} }
switch prefs.CVIcons { switch prefs.CVIcons {
case "true": case "true":
prefs.CVIcons = "show" prefs.CVIcons = constants.CVIconsShow
case "false": case "false":
prefs.CVIcons = "hide" prefs.CVIcons = constants.CVIconsHide
} }
// Store preferences in context // Store preferences in context
@@ -58,11 +60,11 @@ func GetPreferences(r *http.Request) *Preferences {
if !ok { if !ok {
// Return default preferences if not found // Return default preferences if not found
return &Preferences{ return &Preferences{
CVLength: "short", CVLength: constants.CVLengthShort,
CVIcons: "show", CVIcons: constants.CVIconsShow,
CVLanguage: "en", CVLanguage: constants.LangEnglish,
CVTheme: "default", CVTheme: constants.CVThemeDefault,
ColorTheme: "light", ColorTheme: constants.ColorThemeLight,
} }
} }
return prefs return prefs
@@ -100,42 +102,42 @@ func GetColorTheme(r *http.Request) string {
// IsLongCV returns true if the user prefers long CV format // IsLongCV returns true if the user prefers long CV format
func IsLongCV(r *http.Request) bool { func IsLongCV(r *http.Request) bool {
return GetCVLength(r) == "long" return GetCVLength(r) == constants.CVLengthLong
} }
// IsShortCV returns true if the user prefers short CV format // IsShortCV returns true if the user prefers short CV format
func IsShortCV(r *http.Request) bool { func IsShortCV(r *http.Request) bool {
return GetCVLength(r) == "short" return GetCVLength(r) == constants.CVLengthShort
} }
// ShowIcons returns true if icons should be visible // ShowIcons returns true if icons should be visible
func ShowIcons(r *http.Request) bool { func ShowIcons(r *http.Request) bool {
return GetCVIcons(r) == "show" return GetCVIcons(r) == constants.CVIconsShow
} }
// HideIcons returns true if icons should be hidden // HideIcons returns true if icons should be hidden
func HideIcons(r *http.Request) bool { func HideIcons(r *http.Request) bool {
return GetCVIcons(r) == "hide" return GetCVIcons(r) == constants.CVIconsHide
} }
// IsCleanTheme returns true if clean theme is selected // IsCleanTheme returns true if clean theme is selected
func IsCleanTheme(r *http.Request) bool { func IsCleanTheme(r *http.Request) bool {
return GetCVTheme(r) == "clean" return GetCVTheme(r) == constants.CVThemeClean
} }
// IsDefaultTheme returns true if default theme is selected // IsDefaultTheme returns true if default theme is selected
func IsDefaultTheme(r *http.Request) bool { func IsDefaultTheme(r *http.Request) bool {
return GetCVTheme(r) == "default" return GetCVTheme(r) == constants.CVThemeDefault
} }
// IsDarkMode returns true if dark mode is enabled // IsDarkMode returns true if dark mode is enabled
func IsDarkMode(r *http.Request) bool { func IsDarkMode(r *http.Request) bool {
return GetColorTheme(r) == "dark" return GetColorTheme(r) == constants.ColorThemeDark
} }
// IsLightMode returns true if light mode is enabled // IsLightMode returns true if light mode is enabled
func IsLightMode(r *http.Request) bool { func IsLightMode(r *http.Request) bool {
return GetColorTheme(r) == "light" return GetColorTheme(r) == constants.ColorThemeLight
} }
// SetPreferenceCookie sets a preference cookie (1 year expiry) // SetPreferenceCookie sets a preference cookie (1 year expiry)
@@ -143,8 +145,8 @@ func SetPreferenceCookie(w http.ResponseWriter, name string, value string) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: name, Name: name,
Value: value, Value: value,
Path: "/", Path: constants.CookiePath,
MaxAge: 365 * 24 * 60 * 60, // 1 year MaxAge: constants.CookieMaxAge,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
Secure: isProductionMode(), // Secure in production with HTTPS Secure: isProductionMode(), // Secure in production with HTTPS
@@ -153,8 +155,8 @@ func SetPreferenceCookie(w http.ResponseWriter, name string, value string) {
// isProductionMode checks if the application is running in production // isProductionMode checks if the application is running in production
func isProductionMode() bool { func isProductionMode() bool {
env := os.Getenv("GO_ENV") env := os.Getenv(constants.EnvVarGOEnv)
return env == "production" || env == "prod" return env == constants.EnvProduction || env == "prod"
} }
// getPreferenceCookie gets a preference cookie value, returns default if not found // getPreferenceCookie gets a preference cookie value, returns default if not found
+23 -22
View File
@@ -6,25 +6,27 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants"
) )
// SecurityHeaders adds production-grade security headers to responses // SecurityHeaders adds production-grade security headers to responses
func SecurityHeaders(next http.Handler) http.Handler { func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prevent clickjacking // Prevent clickjacking
w.Header().Set("X-Frame-Options", "SAMEORIGIN") w.Header().Set(constants.HeaderXFrameOptions, constants.FrameOptionsSameOrigin)
// Prevent MIME type sniffing // Prevent MIME type sniffing
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set(constants.HeaderXContentTypeOpts, constants.NoSniff)
// XSS Protection (legacy but still useful for older browsers) // XSS Protection (legacy but still useful for older browsers)
w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set(constants.HeaderXXSSProtection, constants.XSSProtection)
// Referrer policy - strict privacy // Referrer policy - strict privacy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set(constants.HeaderReferrerPolicy, constants.ReferrerPolicy)
// Permissions Policy - disable unnecessary features // Permissions Policy - disable unnecessary features
w.Header().Set("Permissions-Policy", w.Header().Set(constants.HeaderPermissionsPolicy,
"geolocation=(), microphone=(), camera=(), payment=(), usb=(), "+ "geolocation=(), microphone=(), camera=(), payment=(), usb=(), "+
"magnetometer=(), gyroscope=(), accelerometer=()") "magnetometer=(), gyroscope=(), accelerometer=()")
@@ -38,13 +40,12 @@ func SecurityHeaders(next http.Handler) http.Handler {
"frame-ancestors 'self'; " + "frame-ancestors 'self'; " +
"base-uri 'self'; " + "base-uri 'self'; " +
"form-action 'self'" "form-action 'self'"
w.Header().Set("Content-Security-Policy", csp) w.Header().Set(constants.HeaderCSP, csp)
// HSTS - only in production with HTTPS // HSTS - only in production with HTTPS
if os.Getenv("GO_ENV") == "production" { if os.Getenv(constants.EnvVarGOEnv) == constants.EnvProduction {
// 1 year max-age, include subdomains // 1 year max-age, include subdomains
w.Header().Set("Strict-Transport-Security", w.Header().Set(constants.HeaderHSTS, constants.HSTSMaxAge)
"max-age=31536000; includeSubDomains; preload")
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@@ -75,7 +76,7 @@ func OriginChecker(next http.Handler) http.Handler {
} }
// Check Origin header (for CORS requests) // Check Origin header (for CORS requests)
origin := r.Header.Get("Origin") origin := r.Header.Get(constants.HeaderOrigin)
if origin != "" { if origin != "" {
if !isAllowedOrigin(origin, allowedOrigins) { if !isAllowedOrigin(origin, allowedOrigins) {
http.Error(w, "Forbidden: External access not allowed", http.StatusForbidden) http.Error(w, "Forbidden: External access not allowed", http.StatusForbidden)
@@ -84,7 +85,7 @@ func OriginChecker(next http.Handler) http.Handler {
} }
// Check Referer header (for direct requests) // Check Referer header (for direct requests)
referer := r.Header.Get("Referer") referer := r.Header.Get(constants.HeaderReferer)
if referer != "" { if referer != "" {
if !isAllowedOrigin(referer, allowedOrigins) { if !isAllowedOrigin(referer, allowedOrigins) {
http.Error(w, "Forbidden: External access not allowed", http.StatusForbidden) http.Error(w, "Forbidden: External access not allowed", http.StatusForbidden)
@@ -97,7 +98,7 @@ func OriginChecker(next http.Handler) http.Handler {
if origin == "" && referer == "" { if origin == "" && referer == "" {
// For production, you might want to be stricter here // For production, you might want to be stricter here
// For now, allow it (users can bookmark /export/pdf directly) // For now, allow it (users can bookmark /export/pdf directly)
if os.Getenv("GO_ENV") == "production" && r.URL.Path == "/export/pdf" { if os.Getenv(constants.EnvVarGOEnv) == constants.EnvProduction && r.URL.Path == constants.RouteExportPDF {
// In production, require at least a referer for PDF endpoint // In production, require at least a referer for PDF endpoint
http.Error(w, "Forbidden: Direct access not allowed", http.StatusForbidden) http.Error(w, "Forbidden: Direct access not allowed", http.StatusForbidden)
return return
@@ -162,16 +163,16 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler { func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get client IP (handle X-Forwarded-For for proxies) // Get client IP (handle X-Forwarded-For for proxies)
ip := r.Header.Get("X-Forwarded-For") ip := r.Header.Get(constants.HeaderXForwardedFor)
if ip == "" { if ip == "" {
ip = r.Header.Get("X-Real-IP") ip = r.Header.Get(constants.HeaderXRealIP)
} }
if ip == "" { if ip == "" {
ip = strings.Split(r.RemoteAddr, ":")[0] ip = strings.Split(r.RemoteAddr, ":")[0]
} }
if !rl.allow(ip) { if !rl.allow(ip) {
w.Header().Set("Retry-After", "60") w.Header().Set(constants.HeaderRetryAfter, "60")
http.Error(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests) http.Error(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
return return
} }
@@ -226,12 +227,12 @@ func (rl *RateLimiter) cleanup() {
// 1 hour in development, 1 day in production // 1 hour in development, 1 day in production
func CacheControl(next http.Handler) http.Handler { func CacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
maxAge := "3600" // 1 hour cacheValue := constants.CachePublic1Hour
if os.Getenv("GO_ENV") == "production" { if os.Getenv(constants.EnvVarGOEnv) == constants.EnvProduction {
maxAge = "86400" // 1 day cacheValue = constants.CachePublic1Day
} }
w.Header().Set("Cache-Control", "public, max-age="+maxAge) w.Header().Set(constants.HeaderCacheControl, cacheValue)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
@@ -242,12 +243,12 @@ func DynamicCacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// For dynamic HTML pages: short cache, must revalidate // For dynamic HTML pages: short cache, must revalidate
// This improves performance while ensuring fresh content // This improves performance while ensuring fresh content
if os.Getenv("GO_ENV") == "production" { if os.Getenv(constants.EnvVarGOEnv) == constants.EnvProduction {
// Production: 5 minutes cache, but must revalidate // Production: 5 minutes cache, but must revalidate
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") w.Header().Set(constants.HeaderCacheControl, constants.CachePublic5Min)
} else { } else {
// Development: no cache for easier testing // Development: no cache for easier testing
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set(constants.HeaderCacheControl, constants.CacheNoStore)
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
+8 -6
View File
@@ -7,6 +7,8 @@ import (
"os" "os"
"strings" "strings"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants"
) )
// SecurityEvent represents a security-related event // SecurityEvent represents a security-related event
@@ -55,7 +57,7 @@ func LogSecurityEvent(eventType string, r *http.Request, details string) {
EventType: eventType, EventType: eventType,
Severity: severity, Severity: severity,
IP: getClientIP(r), IP: getClientIP(r),
UserAgent: r.Header.Get("User-Agent"), UserAgent: r.Header.Get(constants.HeaderUserAgent),
Method: r.Method, Method: r.Method,
Path: r.URL.Path, Path: r.URL.Path,
Details: details, Details: details,
@@ -72,7 +74,7 @@ func LogSecurityEvent(eventType string, r *http.Request, details string) {
log.Printf("[SECURITY] %s", eventJSON) log.Printf("[SECURITY] %s", eventJSON)
// Also log to separate security log file in production // Also log to separate security log file in production
if os.Getenv("GO_ENV") == "production" { if os.Getenv(constants.EnvVarGOEnv) == constants.EnvProduction {
logToSecurityFile(eventJSON) logToSecurityFile(eventJSON)
} }
} }
@@ -97,14 +99,14 @@ func getSeverity(eventType string) string {
// getClientIP extracts the real client IP from request headers // getClientIP extracts the real client IP from request headers
func getClientIP(r *http.Request) string { func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header (proxy/load balancer) // Check X-Forwarded-For header (proxy/load balancer)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if xff := r.Header.Get(constants.HeaderXForwardedFor); xff != "" {
// Take first IP from comma-separated list // Take first IP from comma-separated list
ips := strings.Split(xff, ",") ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0]) return strings.TrimSpace(ips[0])
} }
// Check X-Real-IP header // Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" { if xri := r.Header.Get(constants.HeaderXRealIP); xri != "" {
return xri return xri
} }
@@ -179,7 +181,7 @@ func SecurityLogger(next http.Handler) http.Handler {
EventType: "REQUEST", EventType: "REQUEST",
Severity: SeverityInfo, Severity: SeverityInfo,
IP: getClientIP(r), IP: getClientIP(r),
UserAgent: r.Header.Get("User-Agent"), UserAgent: r.Header.Get(constants.HeaderUserAgent),
Method: r.Method, Method: r.Method,
Path: r.URL.Path, Path: r.URL.Path,
Details: string(detailsJSON), Details: string(detailsJSON),
@@ -201,7 +203,7 @@ func SecurityLogger(next http.Handler) http.Handler {
EventType: "HTTP_ERROR", EventType: "HTTP_ERROR",
Severity: severity, Severity: severity,
IP: getClientIP(r), IP: getClientIP(r),
UserAgent: r.Header.Get("User-Agent"), UserAgent: r.Header.Get(constants.HeaderUserAgent),
Method: r.Method, Method: r.Method,
Path: r.URL.Path, Path: r.URL.Path,
Details: http.StatusText(wrapped.status), Details: http.StatusText(wrapped.status),