refactor: use 'c' alias for constants package

- Update all imports from 'constants' to 'c' for brevity
- Replace all 'constants.' references with 'c.'
- Fix remaining hardcoded content-type headers in httputil
- Fix remaining hardcoded User-Agent and Accept headers
- Rename CSRF receiver from 'c' to 'csrf' to avoid conflict
- Add ContentTypePlainSimple constant for Accept header matching
- Fix JSONCached to use proper integer formatting
This commit is contained in:
juanatsap
2025-12-06 16:31:42 +00:00
parent 2c7f8de242
commit 30ed21ff7a
21 changed files with 1335 additions and 167 deletions
+1162
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -5,7 +5,7 @@ import (
"os" "os"
"strconv" "strconv"
"github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
) )
// Config holds all application configuration // Config holds all application configuration
@@ -50,7 +50,7 @@ type EmailConfig struct {
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Port: getEnv(constants.EnvVarPort, constants.DefaultPort), Port: getEnv(c.EnvVarPort, c.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),
@@ -105,6 +105,6 @@ func getEnvAsBool(key string, defaultValue bool) bool {
} }
func isDevelopment() bool { func isDevelopment() bool {
env := getEnv(constants.EnvVarGOEnv, constants.EnvDevelopment) env := getEnv(c.EnvVarGOEnv, c.EnvDevelopment)
return env == constants.EnvDevelopment || env == "dev" return env == c.EnvDevelopment || env == "dev"
} }
+1
View File
@@ -12,6 +12,7 @@ const (
ContentTypeHTML = "text/html; charset=utf-8" ContentTypeHTML = "text/html; charset=utf-8"
ContentTypeHTMLFragment = "text/html" // For HTMX fragments ContentTypeHTMLFragment = "text/html" // For HTMX fragments
ContentTypePlainText = "text/plain; charset=utf-8" ContentTypePlainText = "text/plain; charset=utf-8"
ContentTypePlainSimple = "text/plain" // For Accept header matching
ContentTypePDF = "application/pdf" ContentTypePDF = "application/pdf"
ContentTypeFormURLEnc = "application/x-www-form-urlencoded" ContentTypeFormURLEnc = "application/x-www-form-urlencoded"
) )
+3 -3
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"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/services"
"github.com/juanatsap/cv-site/internal/templates" "github.com/juanatsap/cv-site/internal/templates"
) )
@@ -143,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(constants.HeaderContentType, constants.ContentTypeHTML) w.Header().Set(c.HeaderContentType, c.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)
@@ -173,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(constants.HeaderContentType, constants.ContentTypeHTML) w.Header().Set(c.HeaderContentType, c.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)
+3 -3
View File
@@ -5,7 +5,7 @@ import (
"log" "log"
"net/http" "net/http"
"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"
) )
@@ -89,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(constants.HeaderContentType, constants.ContentTypeJSON) w.Header().Set(c.HeaderContentType, c.ContentTypeJSON)
w.Header().Set(constants.HeaderCacheControl, constants.CachePublic1Hour) w.Header().Set(c.HeaderCacheControl, c.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)
+3 -3
View File
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"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/services"
) )
@@ -173,7 +173,7 @@ func (h *CVHandler) renderContactSuccess(w http.ResponseWriter, r *http.Request,
} }
// Render the success template // Render the success template
w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML) w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
tmpl, err := h.templates.Render("contact-success") tmpl, err := h.templates.Render("contact-success")
@@ -224,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(constants.HeaderContentType, constants.ContentTypeHTML) w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
tmpl, err := h.templates.Render("contact-error") tmpl, err := h.templates.Render("contact-error")
+2 -2
View File
@@ -11,7 +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" c "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"
) )
@@ -343,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(constants.EnvVarGOEnv) == constants.EnvProduction isProduction := os.Getenv(c.EnvVarGOEnv) == c.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) {
+18 -18
View File
@@ -3,7 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"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/middleware" "github.com/juanatsap/cv-site/internal/middleware"
) )
@@ -25,13 +25,13 @@ func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) {
currentLength := prefs.CVLength currentLength := prefs.CVLength
// Toggle state // Toggle state
newLength := constants.CVLengthLong newLength := c.CVLengthLong
if currentLength == constants.CVLengthLong { if currentLength == c.CVLengthLong {
newLength = constants.CVLengthShort newLength = c.CVLengthShort
} }
// Save new state // Save new state
middleware.SetPreferenceCookie(w, constants.CookieCVLength, newLength) middleware.SetPreferenceCookie(w, c.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
@@ -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 := constants.CVIconsHide newIcons := c.CVIconsHide
if currentIcons == constants.CVIconsHide { if currentIcons == c.CVIconsHide {
newIcons = constants.CVIconsShow newIcons = c.CVIconsShow
} }
// Save new state // Save new state
middleware.SetPreferenceCookie(w, constants.CookieCVIcons, newIcons) middleware.SetPreferenceCookie(w, c.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
@@ -73,7 +73,7 @@ func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) {
} }
// Save language preference // Save language preference
middleware.SetPreferenceCookie(w, constants.CookieCVLanguage, lang) middleware.SetPreferenceCookie(w, c.CookieCVLanguage, lang)
// Prepare template data // Prepare template data
data, err := h.prepareTemplateData(lang) data, err := h.prepareTemplateData(lang)
@@ -86,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 == constants.CVLengthLong { if prefs.CVLength == c.CVLengthLong {
data["CVLengthClass"] = "cv-long" data["CVLengthClass"] = "cv-long"
} else { } else {
data["CVLengthClass"] = "cv-short" data["CVLengthClass"] = "cv-short"
} }
data["ShowIcons"] = (prefs.CVIcons == constants.CVIconsShow) data["ShowIcons"] = (prefs.CVIcons == c.CVIconsShow)
data["ThemeClean"] = (prefs.CVTheme == constants.CVThemeClean) data["ThemeClean"] = (prefs.CVTheme == c.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")
@@ -101,7 +101,7 @@ func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML) w.Header().Set(c.HeaderContentType, c.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
@@ -119,13 +119,13 @@ func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) {
currentTheme := prefs.CVTheme currentTheme := prefs.CVTheme
// Toggle state // Toggle state
newTheme := constants.CVThemeClean newTheme := c.CVThemeClean
if currentTheme == constants.CVThemeClean { if currentTheme == c.CVThemeClean {
newTheme = constants.CVThemeDefault newTheme = c.CVThemeDefault
} }
// Save new state // Save new state
middleware.SetPreferenceCookie(w, constants.CookieCVTheme, newTheme) middleware.SetPreferenceCookie(w, c.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
+12 -12
View File
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"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/middleware" "github.com/juanatsap/cv-site/internal/middleware"
"github.com/juanatsap/cv-site/internal/pdf" "github.com/juanatsap/cv-site/internal/pdf"
@@ -70,7 +70,7 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML) w.Header().Set(c.HeaderContentType, c.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
@@ -99,7 +99,7 @@ func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML) w.Header().Set(c.HeaderContentType, c.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
@@ -127,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 !constants.SupportedLanguages[lang] { if !c.SupportedLanguages[lang] {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -147,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{
constants.CookieCVLength: constants.CVLengthShort, c.CookieCVLength: c.CVLengthShort,
constants.CookieCVIcons: constants.CVIconsShow, c.CookieCVIcons: c.CVIconsShow,
constants.CookieCVLanguage: lang, c.CookieCVLanguage: lang,
constants.CookieCVTheme: constants.CVThemeDefault, // with_skills = default theme c.CookieCVTheme: c.CVThemeDefault, // with_skills = default theme
constants.CookieColorTheme: constants.ColorThemeLight, // Always light for PDFs c.CookieColorTheme: c.ColorThemeLight, // Always light for PDFs
} }
// Construct URL for PDF generation // Construct URL for PDF generation
@@ -170,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(constants.HeaderContentType, constants.ContentTypePDF) w.Header().Set(c.HeaderContentType, c.ContentTypePDF)
w.Header().Set(constants.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename)) w.Header().Set(c.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename))
w.Header().Set(constants.HeaderContentLength, fmt.Sprintf("%d", len(pdfData))) w.Header().Set(c.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 -11
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants" c "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"
) )
@@ -38,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{
constants.CookieCVLength: req.Length, c.CookieCVLength: req.Length,
constants.CookieCVIcons: req.Icons, c.CookieCVIcons: req.Icons,
constants.CookieCVLanguage: req.Lang, c.CookieCVLanguage: req.Lang,
} }
// Set theme cookie based on version parameter // Set theme cookie based on version parameter
if req.Version == constants.CVThemeClean { if req.Version == c.CVThemeClean {
cookies[constants.CookieCVTheme] = constants.CVThemeClean cookies[c.CookieCVTheme] = c.CVThemeClean
} else { } else {
cookies[constants.CookieCVTheme] = constants.CVThemeDefault cookies[c.CookieCVTheme] = c.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[constants.CookieColorTheme] = constants.ColorThemeLight cookies[c.CookieColorTheme] = c.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)
@@ -111,9 +111,9 @@ func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) {
} }
// Set response headers // Set response headers
w.Header().Set(constants.HeaderContentType, constants.ContentTypePDF) w.Header().Set(c.HeaderContentType, c.ContentTypePDF)
w.Header().Set(constants.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename)) w.Header().Set(c.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename))
w.Header().Set(constants.HeaderContentLength, fmt.Sprintf("%d", len(pdfData))) w.Header().Set(c.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 {
+6 -6
View File
@@ -11,7 +11,7 @@ import (
"text/template" "text/template"
"time" "time"
"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"
) )
@@ -36,7 +36,7 @@ var textBrowsers = []string{
// isTextBrowser detects if the request comes from a text-based browser or CLI tool // isTextBrowser detects if the request comes from a text-based browser or CLI tool
func isTextBrowser(r *http.Request) bool { func isTextBrowser(r *http.Request) bool {
ua := strings.ToLower(r.Header.Get("User-Agent")) ua := strings.ToLower(r.Header.Get(c.HeaderUserAgent))
// Check for known text browsers // Check for known text browsers
for _, browser := range textBrowsers { for _, browser := range textBrowsers {
@@ -46,8 +46,8 @@ func isTextBrowser(r *http.Request) bool {
} }
// Check Accept header - if client prefers text/plain // Check Accept header - if client prefers text/plain
accept := r.Header.Get("Accept") accept := r.Header.Get(c.HeaderAccept)
return strings.HasPrefix(accept, "text/plain") return strings.HasPrefix(accept, c.ContentTypePlainSimple)
} }
// ============================================================================== // ==============================================================================
@@ -150,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(constants.HeaderContentType, constants.ContentTypePlainText) w.Header().Set(c.HeaderContentType, c.ContentTypePlainText)
w.Header().Set(constants.HeaderXContentTypeOpts, constants.NoSniff) w.Header().Set(c.HeaderXContentTypeOpts, c.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" {
+5 -5
View File
@@ -6,7 +6,7 @@ import (
"log" "log"
"net/http" "net/http"
"github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
) )
// ErrorResponse represents a structured error response // ErrorResponse represents a structured error response
@@ -64,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 == constants.ContentTypeJSON isJSON := accept == c.ContentTypeJSON
isHTMX := r.Header.Get(constants.HeaderHXRequest) != "" isHTMX := r.Header.Get(c.HeaderHXRequest) != ""
if isJSON { if isJSON {
// JSON response // JSON response
w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON) w.Header().Set(c.HeaderContentType, c.ContentTypeJSON)
w.WriteHeader(appErr.StatusCode) w.WriteHeader(appErr.StatusCode)
response := ErrorResponse{ response := ErrorResponse{
@@ -90,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(constants.HeaderContentType, constants.ContentTypeHTMLFragment) w.Header().Set(c.HeaderContentType, c.ContentTypeHTMLFragment)
w.WriteHeader(appErr.StatusCode) w.WriteHeader(appErr.StatusCode)
message := appErr.Message message := appErr.Message
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
) )
// HealthResponse represents the health check response // HealthResponse represents the health check response
@@ -36,7 +36,7 @@ func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
Version: h.version, Version: h.version,
} }
w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON) w.Header().Set(c.HeaderContentType, c.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)
+5 -5
View File
@@ -4,15 +4,15 @@ package httputil
import ( import (
"net/http" "net/http"
"github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
) )
// Lang extracts and validates the language from query params. // Lang extracts and validates the language from query params.
// Returns the language or default if invalid/missing. // Returns the language or default if invalid/missing.
func Lang(r *http.Request) string { func Lang(r *http.Request) string {
lang := r.URL.Query().Get("lang") lang := r.URL.Query().Get("lang")
if lang == "" || !constants.SupportedLanguages[lang] { if lang == "" || !c.SupportedLanguages[lang] {
return constants.LangDefault return c.LangDefault
} }
return lang return lang
} }
@@ -22,9 +22,9 @@ func Lang(r *http.Request) string {
func LangOrError(r *http.Request) (string, bool) { func LangOrError(r *http.Request) (string, bool) {
lang := r.URL.Query().Get("lang") lang := r.URL.Query().Get("lang")
if lang == "" { if lang == "" {
return constants.LangDefault, true return c.LangDefault, true
} }
if !constants.SupportedLanguages[lang] { if !c.SupportedLanguages[lang] {
return "", false return "", false
} }
return lang, true return lang, true
+6 -3
View File
@@ -2,12 +2,15 @@ package httputil
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
c "github.com/juanatsap/cv-site/internal/constants"
) )
// JSON writes a JSON response with the given status code. // JSON writes a JSON response with the given status code.
func JSON(w http.ResponseWriter, status int, data interface{}) error { func JSON(w http.ResponseWriter, status int, data interface{}) error {
w.Header().Set("Content-Type", "application/json") w.Header().Set(c.HeaderContentType, c.ContentTypeJSON)
w.WriteHeader(status) w.WriteHeader(status)
return json.NewEncoder(w).Encode(data) return json.NewEncoder(w).Encode(data)
} }
@@ -19,13 +22,13 @@ func JSONOk(w http.ResponseWriter, data interface{}) error {
// JSONCached writes a JSON response with caching headers. // JSONCached writes a JSON response with caching headers.
func JSONCached(w http.ResponseWriter, data interface{}, maxAge int) error { func JSONCached(w http.ResponseWriter, data interface{}, maxAge int) error {
w.Header().Set("Cache-Control", "public, max-age="+string(rune(maxAge))) w.Header().Set(c.HeaderCacheControl, fmt.Sprintf("public, max-age=%d", maxAge))
return JSONOk(w, data) return JSONOk(w, data)
} }
// HTML sets HTML content type header. // HTML sets HTML content type header.
func HTML(w http.ResponseWriter) { func HTML(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
} }
// NoContent sends a 204 No Content response. // NoContent sends a 204 No Content response.
+7 -7
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
) )
const ( const (
@@ -23,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(constants.HeaderUserAgent) userAgent := r.Header.Get(c.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)
@@ -31,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(constants.HeaderReferer) referer := r.Header.Get(c.HeaderReferer)
origin := r.Header.Get(constants.HeaderOrigin) origin := r.Header.Get(c.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))
@@ -43,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(constants.HeaderHXRequest) == "true" hasHTMXHeader := r.Header.Get(c.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"
@@ -98,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(constants.HeaderXForwardedFor) ip := r.Header.Get(c.HeaderXForwardedFor)
if ip != "" { if ip != "" {
// Take first IP if multiple // Take first IP if multiple
ips := strings.Split(ip, ",") ips := strings.Split(ip, ",")
@@ -106,7 +106,7 @@ func getRequestIP(r *http.Request) string {
} }
// Try X-Real-IP // Try X-Real-IP
ip = r.Header.Get(constants.HeaderXRealIP) ip = r.Header.Get(c.HeaderXRealIP)
if ip != "" { if ip != "" {
return ip return ip
} }
+6 -6
View File
@@ -6,7 +6,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants" c "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
@@ -39,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(constants.HeaderXForwardedFor) ip := r.Header.Get(c.HeaderXForwardedFor)
if ip == "" { if ip == "" {
ip = r.Header.Get(constants.HeaderXRealIP) ip = r.Header.Get(c.HeaderXRealIP)
} }
if ip == "" { if ip == "" {
ip = strings.Split(r.RemoteAddr, ":")[0] ip = strings.Split(r.RemoteAddr, ":")[0]
@@ -54,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(constants.HeaderHXRequest) != "" isHTMX := r.Header.Get(c.HeaderHXRequest) != ""
if isHTMX { if isHTMX {
// Return HTMX-friendly error // Return HTMX-friendly error
w.Header().Set(constants.HeaderContentType, constants.ContentTypeHTML) w.Header().Set(c.HeaderContentType, c.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(constants.HeaderRetryAfter, "3600") // 1 hour w.Header().Set(c.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
+24 -22
View File
@@ -8,6 +8,8 @@ import (
"net/http" "net/http"
"sync" "sync"
"time" "time"
c "github.com/juanatsap/cv-site/internal/constants"
) )
const ( const (
@@ -44,18 +46,18 @@ func NewCSRFProtection() *CSRFProtection {
// Middleware provides CSRF protection for state-changing operations // Middleware provides CSRF protection for state-changing operations
// GET requests: Generate and set CSRF token // GET requests: Generate and set CSRF token
// POST/PUT/DELETE: Validate CSRF token // POST/PUT/DELETE: Validate CSRF token
func (c *CSRFProtection) Middleware(next http.Handler) http.Handler { func (csrf *CSRFProtection) 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) {
// Only validate on state-changing methods // Only validate on state-changing methods
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete { if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
if !c.validateToken(r) { if !csrf.validateToken(r) {
log.Printf("SECURITY: CSRF validation failed from IP %s", getClientIP(r)) log.Printf("SECURITY: CSRF validation failed from IP %s", getClientIP(r))
// Check if HTMX request // Check if HTMX request
isHTMX := r.Header.Get("HX-Request") != "" isHTMX := r.Header.Get(c.HeaderHXRequest) != ""
if isHTMX { if isHTMX {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`<div class="alert alert-error"> _, _ = w.Write([]byte(`<div class="alert alert-error">
<h3>Security Error</h3> <h3>Security Error</h3>
@@ -73,7 +75,7 @@ func (c *CSRFProtection) Middleware(next http.Handler) http.Handler {
} }
// generateToken creates a new CSRF token // generateToken creates a new CSRF token
func (c *CSRFProtection) generateToken() (string, error) { func (csrf *CSRFProtection) generateToken() (string, error) {
bytes := make([]byte, csrfTokenLength) bytes := make([]byte, csrfTokenLength)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {
return "", err return "", err
@@ -82,26 +84,26 @@ func (c *CSRFProtection) generateToken() (string, error) {
token := base64.URLEncoding.EncodeToString(bytes) token := base64.URLEncoding.EncodeToString(bytes)
// Store token with expiration // Store token with expiration
c.mu.Lock() csrf.mu.Lock()
c.tokens[token] = &csrfTokenEntry{ csrf.tokens[token] = &csrfTokenEntry{
token: token, token: token,
expiresAt: time.Now().Add(csrfTokenTTL), expiresAt: time.Now().Add(csrfTokenTTL),
} }
c.mu.Unlock() csrf.mu.Unlock()
return token, nil return token, nil
} }
// GetToken retrieves or generates a CSRF token for the request // GetToken retrieves or generates a CSRF token for the request
// This should be called when rendering forms // This should be called when rendering forms
func (c *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(csrfCookieName)
if err == nil && cookie.Value != "" { if err == nil && cookie.Value != "" {
// Validate existing token // Validate existing token
c.mu.RLock() csrf.mu.RLock()
entry, exists := c.tokens[cookie.Value] entry, exists := csrf.tokens[cookie.Value]
c.mu.RUnlock() csrf.mu.RUnlock()
if exists && time.Now().Before(entry.expiresAt) { if exists && time.Now().Before(entry.expiresAt) {
// Token is valid, return it // Token is valid, return it
@@ -110,7 +112,7 @@ func (c *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (strin
} }
// Generate new token // Generate new token
token, err := c.generateToken() token, err := csrf.generateToken()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to generate CSRF token: %w", err) return "", fmt.Errorf("failed to generate CSRF token: %w", err)
} }
@@ -130,7 +132,7 @@ func (c *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (strin
} }
// validateToken validates the CSRF token from the request // validateToken validates the CSRF token from the request
func (c *CSRFProtection) validateToken(r *http.Request) bool { func (csrf *CSRFProtection) validateToken(r *http.Request) bool {
// Get token from form // Get token from form
var formToken string var formToken string
@@ -163,9 +165,9 @@ func (c *CSRFProtection) validateToken(r *http.Request) bool {
} }
// Validate token exists and is not expired // Validate token exists and is not expired
c.mu.RLock() csrf.mu.RLock()
entry, exists := c.tokens[formToken] entry, exists := csrf.tokens[formToken]
c.mu.RUnlock() csrf.mu.RUnlock()
if !exists { if !exists {
log.Printf("CSRF: Token not found in store") log.Printf("CSRF: Token not found in store")
@@ -181,19 +183,19 @@ func (c *CSRFProtection) validateToken(r *http.Request) bool {
} }
// cleanup removes expired tokens periodically // cleanup removes expired tokens periodically
func (c *CSRFProtection) cleanup() { func (csrf *CSRFProtection) cleanup() {
ticker := time.NewTicker(1 * time.Hour) ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
c.mu.Lock() csrf.mu.Lock()
now := time.Now() now := time.Now()
for token, entry := range c.tokens { for token, entry := range csrf.tokens {
if now.After(entry.expiresAt) { if now.After(entry.expiresAt) {
delete(c.tokens, token) delete(csrf.tokens, token)
} }
} }
c.mu.Unlock() csrf.mu.Unlock()
} }
} }
+26 -26
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"os" "os"
"github.com/juanatsap/cv-site/internal/constants" c "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
@@ -30,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, constants.CookieCVLength, constants.CVLengthShort), CVLength: getPreferenceCookie(r, c.CookieCVLength, c.CVLengthShort),
CVIcons: getPreferenceCookie(r, constants.CookieCVIcons, constants.CVIconsShow), CVIcons: getPreferenceCookie(r, c.CookieCVIcons, c.CVIconsShow),
CVLanguage: getPreferenceCookie(r, constants.CookieCVLanguage, constants.LangEnglish), CVLanguage: getPreferenceCookie(r, c.CookieCVLanguage, c.LangEnglish),
CVTheme: getPreferenceCookie(r, constants.CookieCVTheme, constants.CVThemeDefault), CVTheme: getPreferenceCookie(r, c.CookieCVTheme, c.CVThemeDefault),
ColorTheme: getPreferenceCookie(r, constants.CookieColorTheme, constants.ColorThemeLight), ColorTheme: getPreferenceCookie(r, c.CookieColorTheme, c.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 = constants.CVLengthLong prefs.CVLength = c.CVLengthLong
} }
switch prefs.CVIcons { switch prefs.CVIcons {
case "true": case "true":
prefs.CVIcons = constants.CVIconsShow prefs.CVIcons = c.CVIconsShow
case "false": case "false":
prefs.CVIcons = constants.CVIconsHide prefs.CVIcons = c.CVIconsHide
} }
// Store preferences in context // Store preferences in context
@@ -60,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: constants.CVLengthShort, CVLength: c.CVLengthShort,
CVIcons: constants.CVIconsShow, CVIcons: c.CVIconsShow,
CVLanguage: constants.LangEnglish, CVLanguage: c.LangEnglish,
CVTheme: constants.CVThemeDefault, CVTheme: c.CVThemeDefault,
ColorTheme: constants.ColorThemeLight, ColorTheme: c.ColorThemeLight,
} }
} }
return prefs return prefs
@@ -102,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) == constants.CVLengthLong return GetCVLength(r) == c.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) == constants.CVLengthShort return GetCVLength(r) == c.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) == constants.CVIconsShow return GetCVIcons(r) == c.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) == constants.CVIconsHide return GetCVIcons(r) == c.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) == constants.CVThemeClean return GetCVTheme(r) == c.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) == constants.CVThemeDefault return GetCVTheme(r) == c.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) == constants.ColorThemeDark return GetColorTheme(r) == c.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) == constants.ColorThemeLight return GetColorTheme(r) == c.ColorThemeLight
} }
// SetPreferenceCookie sets a preference cookie (1 year expiry) // SetPreferenceCookie sets a preference cookie (1 year expiry)
@@ -145,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: constants.CookiePath, Path: c.CookiePath,
MaxAge: constants.CookieMaxAge, MaxAge: c.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
@@ -155,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(constants.EnvVarGOEnv) env := os.Getenv(c.EnvVarGOEnv)
return env == constants.EnvProduction || env == "prod" return env == c.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
+22 -22
View File
@@ -7,26 +7,26 @@ import (
"sync" "sync"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants" c "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(constants.HeaderXFrameOptions, constants.FrameOptionsSameOrigin) w.Header().Set(c.HeaderXFrameOptions, c.FrameOptionsSameOrigin)
// Prevent MIME type sniffing // Prevent MIME type sniffing
w.Header().Set(constants.HeaderXContentTypeOpts, constants.NoSniff) w.Header().Set(c.HeaderXContentTypeOpts, c.NoSniff)
// XSS Protection (legacy but still useful for older browsers) // XSS Protection (legacy but still useful for older browsers)
w.Header().Set(constants.HeaderXXSSProtection, constants.XSSProtection) w.Header().Set(c.HeaderXXSSProtection, c.XSSProtection)
// Referrer policy - strict privacy // Referrer policy - strict privacy
w.Header().Set(constants.HeaderReferrerPolicy, constants.ReferrerPolicy) w.Header().Set(c.HeaderReferrerPolicy, c.ReferrerPolicy)
// Permissions Policy - disable unnecessary features // Permissions Policy - disable unnecessary features
w.Header().Set(constants.HeaderPermissionsPolicy, w.Header().Set(c.HeaderPermissionsPolicy,
"geolocation=(), microphone=(), camera=(), payment=(), usb=(), "+ "geolocation=(), microphone=(), camera=(), payment=(), usb=(), "+
"magnetometer=(), gyroscope=(), accelerometer=()") "magnetometer=(), gyroscope=(), accelerometer=()")
@@ -40,12 +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(constants.HeaderCSP, csp) w.Header().Set(c.HeaderCSP, csp)
// HSTS - only in production with HTTPS // HSTS - only in production with HTTPS
if os.Getenv(constants.EnvVarGOEnv) == constants.EnvProduction { if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction {
// 1 year max-age, include subdomains // 1 year max-age, include subdomains
w.Header().Set(constants.HeaderHSTS, constants.HSTSMaxAge) w.Header().Set(c.HeaderHSTS, c.HSTSMaxAge)
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@@ -76,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(constants.HeaderOrigin) origin := r.Header.Get(c.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)
@@ -85,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(constants.HeaderReferer) referer := r.Header.Get(c.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)
@@ -98,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(constants.EnvVarGOEnv) == constants.EnvProduction && r.URL.Path == constants.RouteExportPDF { if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction && r.URL.Path == c.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
@@ -163,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(constants.HeaderXForwardedFor) ip := r.Header.Get(c.HeaderXForwardedFor)
if ip == "" { if ip == "" {
ip = r.Header.Get(constants.HeaderXRealIP) ip = r.Header.Get(c.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(constants.HeaderRetryAfter, "60") w.Header().Set(c.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
} }
@@ -227,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) {
cacheValue := constants.CachePublic1Hour cacheValue := c.CachePublic1Hour
if os.Getenv(constants.EnvVarGOEnv) == constants.EnvProduction { if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction {
cacheValue = constants.CachePublic1Day cacheValue = c.CachePublic1Day
} }
w.Header().Set(constants.HeaderCacheControl, cacheValue) w.Header().Set(c.HeaderCacheControl, cacheValue)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
@@ -243,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(constants.EnvVarGOEnv) == constants.EnvProduction { if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction {
// Production: 5 minutes cache, but must revalidate // Production: 5 minutes cache, but must revalidate
w.Header().Set(constants.HeaderCacheControl, constants.CachePublic5Min) w.Header().Set(c.HeaderCacheControl, c.CachePublic5Min)
} else { } else {
// Development: no cache for easier testing // Development: no cache for easier testing
w.Header().Set(constants.HeaderCacheControl, constants.CacheNoStore) w.Header().Set(c.HeaderCacheControl, c.CacheNoStore)
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/juanatsap/cv-site/internal/constants" c "github.com/juanatsap/cv-site/internal/constants"
) )
// SecurityEvent represents a security-related event // SecurityEvent represents a security-related event
@@ -57,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(constants.HeaderUserAgent), UserAgent: r.Header.Get(c.HeaderUserAgent),
Method: r.Method, Method: r.Method,
Path: r.URL.Path, Path: r.URL.Path,
Details: details, Details: details,
@@ -74,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(constants.EnvVarGOEnv) == constants.EnvProduction { if os.Getenv(c.EnvVarGOEnv) == c.EnvProduction {
logToSecurityFile(eventJSON) logToSecurityFile(eventJSON)
} }
} }
@@ -99,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(constants.HeaderXForwardedFor); xff != "" { if xff := r.Header.Get(c.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(constants.HeaderXRealIP); xri != "" { if xri := r.Header.Get(c.HeaderXRealIP); xri != "" {
return xri return xri
} }
@@ -181,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(constants.HeaderUserAgent), UserAgent: r.Header.Get(c.HeaderUserAgent),
Method: r.Method, Method: r.Method,
Path: r.URL.Path, Path: r.URL.Path,
Details: string(detailsJSON), Details: string(detailsJSON),
@@ -203,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(constants.HeaderUserAgent), UserAgent: r.Header.Get(c.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),