From 4acde64c01eb0898043089310b905c84b8276a5e Mon Sep 17 00:00:00 2001 From: juanatsap Date: Thu, 20 Nov 2025 17:01:50 +0000 Subject: [PATCH] refactor: Split monolithic handler into focused files Split internal/handlers/cv.go (1,001 lines) into 5 focused files: Structure: - cv.go (29 lines) - CVHandler struct + constructor - cv_pages.go (290 lines) - Page handlers (Home, CVContent, DefaultCVShortcut) - cv_pdf.go (153 lines) - PDF export handler (ExportPDF) - cv_htmx.go (218 lines) - HTMX toggle handlers (Length, Icons, Language, Theme) - cv_helpers.go (385 lines) - Helper functions (skills, dates, git, templates, cookies) Benefits: - Single Responsibility: Each file has one clear purpose - Improved Discoverability: Easy to find specific functionality - Reduced Cognitive Load: 200-400 lines per file vs 1,001 - Parallel Development: No conflicts when editing different concerns - Better Organization: Clear section markers and grouping - Maintainability: Trade +74 lines (+7.4%) for better organization Testing: - All Go tests pass (fileutil, handlers, lang, cv, ui) - Server builds and runs correctly - All HTTP endpoints functional - No breaking changes Documentation: - Create _go-learning/refactorings/003-handler-split.md - Document architecture, benefits, and trade-offs - Explain WHY single package vs separate packages --- .../refactorings/003-handler-split.md | 373 +++++++ internal/handlers/cv.go | 982 +----------------- internal/handlers/cv_helpers.go | 385 +++++++ internal/handlers/cv_htmx.go | 218 ++++ internal/handlers/cv_pages.go | 290 ++++++ internal/handlers/cv_pdf.go | 153 +++ 6 files changed, 1424 insertions(+), 977 deletions(-) create mode 100644 _go-learning/refactorings/003-handler-split.md create mode 100644 internal/handlers/cv_helpers.go create mode 100644 internal/handlers/cv_htmx.go create mode 100644 internal/handlers/cv_pages.go create mode 100644 internal/handlers/cv_pdf.go diff --git a/_go-learning/refactorings/003-handler-split.md b/_go-learning/refactorings/003-handler-split.md new file mode 100644 index 0000000..ca3d6ec --- /dev/null +++ b/_go-learning/refactorings/003-handler-split.md @@ -0,0 +1,373 @@ +# Refactoring #3: Handler Split - From Monolith to Focused Files + +**Date**: 2024-11-20 +**Type**: Code Organization, Maintainability + +## Problem Statement + +After implementing shared utilities and validation (Refactoring #2), the handler file remained problematic: + +- **Single Monolithic File**: `internal/handlers/cv.go` was 1,001 lines +- **Mixed Concerns**: Page rendering, PDF export, HTMX toggles, and helpers all in one file +- **Difficult Navigation**: Finding specific functionality required scrolling through hundreds of lines +- **Poor Separation**: No clear boundaries between different types of handlers + +## Solution + +Split the monolithic handler into focused files by responsibility: + +1. **cv.go** (29 lines) - CVHandler struct + constructor only +2. **cv_pages.go** (290 lines) - Page rendering handlers +3. **cv_pdf.go** (153 lines) - PDF export handler +4. **cv_htmx.go** (218 lines) - HTMX toggle handlers +5. **cv_helpers.go** (385 lines) - Helper functions + +## Architecture + +### Before (Monolithic) + +``` +internal/handlers/cv.go (1,001 lines) +├── CVHandler struct +├── NewCVHandler() +├── Home() (page handler) +├── CVContent() (page handler) +├── DefaultCVShortcut() (page handler) +├── ExportPDF() (PDF handler) +├── ToggleLength() (HTMX handler) +├── ToggleIcons() (HTMX handler) +├── SwitchLanguage() (HTMX handler) +├── ToggleTheme() (HTMX handler) +├── splitSkills() (helper) +├── calculateYearsOfExperience() (helper) +├── calculateDuration() (helper) +├── processProjectDates() (helper) +├── findProjectRoot() (helper) +├── validateRepoPath() (helper) +├── getGitRepoFirstCommitDate() (helper) +├── prepareTemplateData() (helper) +├── getPreferenceCookie() (helper) +└── setPreferenceCookie() (helper) +``` + +### After (Focused Files) + +``` +internal/handlers/ +├── cv.go (29 lines) +│ ├── CVHandler struct +│ └── NewCVHandler() +│ +├── cv_pages.go (290 lines) +│ ├── Home() - Full CV page +│ ├── CVContent() - HTMX content swap +│ └── DefaultCVShortcut() - Shortcut PDF URLs +│ +├── cv_pdf.go (153 lines) +│ └── ExportPDF() - PDF generation with options +│ +├── cv_htmx.go (218 lines) +│ ├── ToggleLength() - Short/long toggle +│ ├── ToggleIcons() - Show/hide icons +│ ├── SwitchLanguage() - EN/ES switching +│ └── ToggleTheme() - Default/clean theme +│ +└── cv_helpers.go (385 lines) + ├── Skills helpers: + │ └── splitSkills() + ├── Date/Duration helpers: + │ ├── calculateYearsOfExperience() + │ ├── calculateDuration() + │ └── processProjectDates() + ├── Git helpers: + │ ├── findProjectRoot() + │ ├── validateRepoPath() + │ └── getGitRepoFirstCommitDate() + ├── Template helpers: + │ └── prepareTemplateData() + └── Cookie helpers: + ├── getPreferenceCookie() + └── setPreferenceCookie() +``` + +## Benefits + +### 1. Single Responsibility Principle (SRP) + +Each file now has ONE clear purpose: + +**cv.go** - Defines the handler structure +```go +// CVHandler handles CV-related requests +// Methods are split across multiple files for better organization: +// - cv_pages.go: Page rendering (Home, CVContent, DefaultCVShortcut) +// - cv_pdf.go: PDF export (ExportPDF) +// - cv_htmx.go: HTMX toggles (ToggleLength, ToggleIcons, SwitchLanguage, ToggleTheme) +// - cv_helpers.go: Helper functions (skills, dates, git, templates, cookies) +type CVHandler struct { + templates *templates.Manager + pdfGenerator *pdf.Generator + serverAddr string +} +``` + +### 2. Improved Discoverability + +**Easy to find functionality:** +- Need to modify page rendering? → `cv_pages.go` +- PDF generation issue? → `cv_pdf.go` +- HTMX toggle not working? → `cv_htmx.go` +- Helper function bug? → `cv_helpers.go` + +### 3. Reduced Cognitive Load + +**Before**: Navigate 1,001 lines to understand one feature +**After**: Open the relevant ~150-400 line file + +### 4. Better Code Organization + +**cv_helpers.go** groups helpers by category with clear section markers: +```go +// ============================================================================== +// SKILLS HELPERS +// ============================================================================== + +// splitSkills splits skill categories between left and right sidebars +func splitSkills(skills []cvmodel.SkillCategory) (left, right []cvmodel.SkillCategory) { + // ... +} + +// ============================================================================== +// DATE/DURATION HELPERS +// ============================================================================== + +// calculateYearsOfExperience calculates years since April 1, 2005 +func calculateYearsOfExperience() int { + // ... +} +``` + +### 5. Parallel Development + +Multiple developers can now work on different handler concerns without conflicts: +- Developer A: Adds new HTMX toggle → edits `cv_htmx.go` +- Developer B: Modifies PDF export → edits `cv_pdf.go` +- Developer C: Adds page handler → edits `cv_pages.go` + +No merge conflicts! + +### 6. Testability + +Each file can have focused tests: +- `cv_pages_test.go` - Page rendering tests +- `cv_pdf_test.go` - PDF generation tests +- `cv_htmx_test.go` - HTMX toggle tests +- `cv_helpers_test.go` - Helper function tests + +### 7. Documentation Clarity + +Each file's purpose is immediately clear from its name and can have targeted documentation. + +## Implementation Details + +### Why These Groupings? + +**cv_pages.go** - All handlers that render full pages or page sections +- `Home()` - Complete HTML page +- `CVContent()` - HTMX content swap +- `DefaultCVShortcut()` - Special PDF shortcut URLs + +**cv_pdf.go** - PDF generation is complex enough to warrant its own file +- Handles multiple query parameters (lang, length, icons, version) +- Manages PDF generation with chromedp +- Complex filename generation logic + +**cv_htmx.go** - All HTMX interactivity handlers +- Similar patterns (toggle states, cookies, out-of-band swaps) +- All follow same structure: read state → toggle → save → render + +**cv_helpers.go** - All supporting functions +- Organized by category with section markers +- Pure functions (no HTTP request/response handling) +- Reusable across handlers + +### Go Package Benefits + +All files are in the same package (`package handlers`), so: +- ✅ Methods can be split across files (Go allows this!) +- ✅ Helper functions accessible without imports +- ✅ No circular dependency issues +- ✅ Same namespace, better organization + +## Code Metrics + +### File Sizes + +| File | Lines | Purpose | Complexity | +|------|-------|---------|------------| +| cv.go | 29 | Struct + constructor | Very Low | +| cv_pages.go | 290 | Page rendering | Medium | +| cv_pdf.go | 153 | PDF export | Medium | +| cv_htmx.go | 218 | HTMX toggles | Low | +| cv_helpers.go | 385 | Helper functions | Low-Medium | +| **Total** | **1,075** | | **Average** | + +### Reduction Achievement + +- **Original**: 1 file × 1,001 lines = **1,001 lines** +- **New**: 5 files × 215 lines avg = **1,075 lines** +- **Net Change**: +74 lines (+7.4%) + +The slight increase is due to: +- Comments documenting each file's purpose +- Section markers in cv_helpers.go for better organization +- More descriptive comments at file level + +**Trade-off**: +74 lines for dramatically improved maintainability and organization. + +### Maintainability Index + +**Before**: +- 1,001 lines to search through +- 19 functions mixed together +- No clear organization + +**After**: +- 29-385 lines per file +- 3-9 functions per file (focused) +- Clear organization by responsibility + +## Testing + +### All Tests Pass + +```bash +$ go test ./... +ok github.com/juanatsap/cv-site/internal/fileutil 0.432s +ok github.com/juanatsap/cv-site/internal/handlers 0.789s +ok github.com/juanatsap/cv-site/internal/lang 0.326s +ok github.com/juanatsap/cv-site/internal/models/cv 0.463s +ok github.com/juanatsap/cv-site/internal/models/ui 0.315s +``` + +### Verification + +1. **Build**: ✅ `go build` succeeds +2. **Tests**: ✅ All unit tests pass +3. **Server**: ✅ Server starts and renders pages +4. **Endpoints**: ✅ All HTTP endpoints functional + +## Why This Approach? + +### Alternative Considered: Separate Packages + +Could we split into separate packages? + +``` +internal/ +├── handlers/pages/ +├── handlers/pdf/ +├── handlers/htmx/ +└── handlers/helpers/ +``` + +**Why NOT:** +- Creates circular dependencies (pages need helpers, helpers need CVHandler) +- More complex imports +- Breaks Go's "methods on types" pattern (can't split CVHandler methods across packages) + +**Why Single Package:** +- ✅ Methods can be defined in any file +- ✅ Helpers accessible without imports +- ✅ Single namespace, no confusion +- ✅ Go's design encourages this pattern + +### Go Best Practices + +This approach follows **Go best practices**: + +1. **Package organization by feature, not by layer** + - All CV handler code stays in `handlers` package + - Files split by sub-feature (pages, PDF, HTMX, helpers) + +2. **Methods split across files** + - Go allows defining methods on a type in any file in the same package + - CVHandler methods spread across multiple files naturally + +3. **Clear file naming** + - Prefix indicates grouping: `cv_pages.go`, `cv_pdf.go`, `cv_htmx.go` + - Easy to find related functionality + +## Interview Talking Points + +### 1. Code Organization + +"I refactored a 1,001-line monolithic handler into 5 focused files (29-385 lines each), improving discoverability and maintainability while following Go's single-package-multiple-files pattern." + +### 2. Single Responsibility Principle + +"Each file now has one clear purpose: cv_pages handles page rendering, cv_pdf manages PDF export, cv_htmx handles interactivity, and cv_helpers provides reusable functions." + +### 3. Maintainability Over Brevity + +"I accepted a 7.4% line increase to gain dramatically improved organization. The trade-off of 74 extra lines for better maintainability was worth it." + +### 4. Go Package Patterns + +"I kept all files in one package to avoid circular dependencies and leverage Go's ability to split methods across files, rather than forcing artificial package boundaries." + +### 5. Parallel Development + +"The split enables multiple developers to work on different handler concerns without conflicts, improving team velocity." + +### 6. Progressive Refactoring + +"This is refactoring #3 in a series: #1 separated domain models, #2 added shared utilities and validation, #3 organized handlers. Each step builds on the previous, improving the codebase incrementally." + +## Future Improvements + +1. **Extract Duplicate Logic**: `Home()` and `CVContent()` have similar data preparation - could use `prepareTemplateData()` +2. **Handler Tests**: Add focused tests for each handler file +3. **Middleware Extraction**: Cookie handling could become middleware +4. **Request/Response Types**: Define structs for common request/response patterns +5. **Error Handling**: Centralize error response formatting + +## Related Documentation + +- [Refactoring #1: CV/UI Model Separation](./001-cv-model-separation.md) +- [Refactoring #2: Shared Utilities & Validation](./002-shared-utilities-validation.md) +- [Server Design: Why Goroutines?](../architecture/server-design.md) + +## Commit Message + +``` +refactor: Split monolithic handler into focused files + +Split internal/handlers/cv.go (1,001 lines) into 5 focused files: + +Structure: +- cv.go (29 lines) - CVHandler struct + constructor +- cv_pages.go (290 lines) - Page handlers (Home, CVContent, DefaultCVShortcut) +- cv_pdf.go (153 lines) - PDF export handler (ExportPDF) +- cv_htmx.go (218 lines) - HTMX toggle handlers (Length, Icons, Language, Theme) +- cv_helpers.go (385 lines) - Helper functions (skills, dates, git, templates, cookies) + +Benefits: +- Single Responsibility: Each file has one clear purpose +- Improved Discoverability: Easy to find specific functionality +- Reduced Cognitive Load: 200-400 lines per file vs 1,001 +- Parallel Development: No conflicts when editing different concerns +- Better Organization: Clear section markers and grouping +- Maintainability: Trade +74 lines (+7.4%) for better organization + +Testing: +- All Go tests pass (fileutil, handlers, lang, cv, ui) +- Server builds and runs correctly +- All HTTP endpoints functional +- No breaking changes + +Documentation: +- Create _go-learning/refactorings/003-handler-split.md +- Document architecture, benefits, and trade-offs +- Explain WHY single package vs separate packages +``` diff --git a/internal/handlers/cv.go b/internal/handlers/cv.go index 12c39c5..96a9225 100644 --- a/internal/handlers/cv.go +++ b/internal/handlers/cv.go @@ -1,23 +1,18 @@ package handlers import ( - "context" - "fmt" - "log" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" "time" - cvmodel "github.com/juanatsap/cv-site/internal/models/cv" - uimodel "github.com/juanatsap/cv-site/internal/models/ui" "github.com/juanatsap/cv-site/internal/pdf" "github.com/juanatsap/cv-site/internal/templates" ) // CVHandler handles CV-related requests +// Methods are split across multiple files for better organization: +// - cv_pages.go: Page rendering (Home, CVContent, DefaultCVShortcut) +// - cv_pdf.go: PDF export (ExportPDF) +// - cv_htmx.go: HTMX toggles (ToggleLength, ToggleIcons, SwitchLanguage, ToggleTheme) +// - cv_helpers.go: Helper functions (skills, dates, git, templates, cookies) type CVHandler struct { templates *templates.Manager pdfGenerator *pdf.Generator @@ -32,970 +27,3 @@ func NewCVHandler(tmpl *templates.Manager, serverAddr string) *CVHandler { serverAddr: serverAddr, } } - -// Home renders the full CV page -func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { - // Check if this is a shortcut URL request (cv-jamr-{year}-{lang}.pdf) - if strings.HasPrefix(r.URL.Path, "/cv-jamr-") && strings.HasSuffix(r.URL.Path, ".pdf") { - h.DefaultCVShortcut(w, r) - return - } - - // Get language from query parameter, default to English - lang := r.URL.Query().Get("lang") - if lang == "" { - lang = "en" - } - - // Validate language - if lang != "en" && lang != "es" { - HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) - return - } - - // Load CV data - cv, err := cvmodel.LoadCV(lang) - if err != nil { - HandleError(w, r, DataLoadError(err, "CV")) - return - } - - // Load UI translations - ui, err := uimodel.LoadUI(lang) - if err != nil { - HandleError(w, r, DataLoadError(err, "UI")) - return - } - - // Calculate duration for each experience - for i := range cv.Experience { - cv.Experience[i].Duration = calculateDuration( - cv.Experience[i].StartDate, - cv.Experience[i].EndDate, - cv.Experience[i].Current, - lang, - ) - } - - // Process projects for dynamic dates - for i := range cv.Projects { - processProjectDates(&cv.Projects[i], lang) - } - - // Split skills between left and right sidebars - skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) - - // Calculate years of experience - yearsOfExperience := calculateYearsOfExperience() - - // Get current year - currentYear := time.Now().Year() - - // Read user preferences from cookies - cvLength := getPreferenceCookie(r, "cv-length", "short") - cvIcons := getPreferenceCookie(r, "cv-icons", "show") - cvTheme := getPreferenceCookie(r, "cv-theme", "default") - - // Migrate old preference values to new ones (one-time auto-migration) - if cvLength == "extended" { - cvLength = "long" - setPreferenceCookie(w, "cv-length", "long") - } - switch cvIcons { - case "true": - cvIcons = "show" - setPreferenceCookie(w, "cv-icons", "show") - case "false": - cvIcons = "hide" - setPreferenceCookie(w, "cv-icons", "hide") - } - - // Prepare CV length class - cvLengthClass := "cv-short" - if cvLength == "long" { - cvLengthClass = "cv-long" - } - - // Prepare template data - data := map[string]interface{}{ - "CV": cv, - "UI": ui, - "Lang": lang, - "SkillsLeft": skillsLeft, - "SkillsRight": skillsRight, - "YearsOfExperience": yearsOfExperience, - "CurrentYear": currentYear, - "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), - "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", - "AlternateES": "https://juan.andres.morenorub.io/?lang=es", - "CVLengthClass": cvLengthClass, - "ShowIcons": (cvIcons == "show"), - "ThemeClean": (cvTheme == "clean"), - } - - // Render template - tmpl, err := h.templates.Render("index.html") - if err != nil { - HandleError(w, r, TemplateError(err, "index.html")) - return - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - HandleError(w, r, TemplateError(err, "index.html")) - return - } -} - -// CVContent renders just the CV content for HTMX swaps -func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) { - // Get language from query parameter - lang := r.URL.Query().Get("lang") - if lang == "" { - lang = "en" - } - - // Validate language - if lang != "en" && lang != "es" { - HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) - return - } - - // Load CV data - cv, err := cvmodel.LoadCV(lang) - if err != nil { - HandleError(w, r, DataLoadError(err, "CV")) - return - } - - // Load UI translations - ui, err := uimodel.LoadUI(lang) - if err != nil { - HandleError(w, r, DataLoadError(err, "UI")) - return - } - - // Calculate duration for each experience - for i := range cv.Experience { - cv.Experience[i].Duration = calculateDuration( - cv.Experience[i].StartDate, - cv.Experience[i].EndDate, - cv.Experience[i].Current, - lang, - ) - } - - // Process projects for dynamic dates - for i := range cv.Projects { - processProjectDates(&cv.Projects[i], lang) - } - - // Split skills between left and right sidebars - skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) - - // Calculate years of experience - yearsOfExperience := calculateYearsOfExperience() - - // Get current year - currentYear := time.Now().Year() - - // Prepare template data - data := map[string]interface{}{ - "CV": cv, - "UI": ui, - "Lang": lang, - "SkillsLeft": skillsLeft, - "SkillsRight": skillsRight, - "YearsOfExperience": yearsOfExperience, - "CurrentYear": currentYear, - "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), - "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", - "AlternateES": "https://juan.andres.morenorub.io/?lang=es", - } - - // Render template - tmpl, err := h.templates.Render("cv-content.html") - if err != nil { - HandleError(w, r, TemplateError(err, "cv-content.html")) - return - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - HandleError(w, r, TemplateError(err, "cv-content.html")) - return - } -} - -// DefaultCVShortcut handles shortcut URLs for default CV downloads -// Pattern: /cv-jamr-{year}-{lang}.pdf (e.g., /cv-jamr-2025-en.pdf) -// Validates year matches current year and redirects to default PDF export -func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) { - // Extract path (e.g., "/cv-jamr-2025-en.pdf") - path := r.URL.Path - log.Printf("DefaultCVShortcut called with path: %s", path) - - // Parse filename pattern: cv-jamr-{year}-{lang}.pdf - parts := strings.Split(strings.TrimPrefix(path, "/"), "-") - if len(parts) != 4 { - http.NotFound(w, r) - return - } - - // Extract year and language - yearStr := parts[2] - langWithExt := parts[3] // "en.pdf" or "es.pdf" - lang := strings.TrimSuffix(langWithExt, ".pdf") - - // Validate language - if lang != "en" && lang != "es" { - http.NotFound(w, r) - return - } - - // Validate year matches current year - currentYear := fmt.Sprintf("%d", time.Now().Year()) - if yearStr != currentYear { - // Return 404 if year doesn't match (old or future URLs) - log.Printf("Invalid year in shortcut URL: %s (current: %s)", yearStr, currentYear) - http.NotFound(w, r) - return - } - - // Generate PDF directly instead of redirecting - // This ensures the Content-Disposition filename is respected by browsers - log.Printf("Shortcut URL: %s → generating PDF (short + with_skills)", path) - - // Prepare cookies for PDF generation (short, with_skills, light mode) - cookies := map[string]string{ - "cv-length": "short", - "cv-icons": "show", - "cv-language": lang, - "cv-theme": "default", // with_skills = default theme - "color-theme": "light", // Always light for PDFs - } - - // Construct URL for PDF generation - targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang) - - // Generate PDF with screen render mode (for sidebar layout) - ctx := r.Context() - pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, pdf.RenderModeScreen) - if err != nil { - log.Printf("PDF generation failed for shortcut URL: %v", err) - HandleError(w, r, InternalError(err)) - return - } - - // Use the shortcut filename directly (simple, user-friendly) - filename := filepath.Base(path) // cv-jamr-2025-en.pdf - - // Set response headers with shortcut filename - w.Header().Set("Content-Type", "application/pdf") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData))) - - // Write PDF data - if _, err := w.Write(pdfData); err != nil { - log.Printf("Error writing PDF response: %v", err) - return - } - - log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData)) -} - -// ExportPDF handles PDF export requests using chromedp -func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) { - // Extract and validate query parameters - lang := r.URL.Query().Get("lang") - if lang == "" { - lang = "en" - } - if lang != "en" && lang != "es" { - HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) - return - } - - length := r.URL.Query().Get("length") - if length == "" { - length = "short" - } - if length != "short" && length != "long" { - HandleError(w, r, BadRequestError("Unsupported length. Use 'short' or 'long'")) - return - } - - icons := r.URL.Query().Get("icons") - if icons == "" { - icons = "show" - } - if icons != "show" && icons != "hide" { - HandleError(w, r, BadRequestError("Unsupported icons option. Use 'show' or 'hide'")) - return - } - - version := r.URL.Query().Get("version") - if version == "" { - version = "with_skills" - } - if version != "with_skills" && version != "clean" { - HandleError(w, r, BadRequestError("Unsupported version. Use 'with_skills' or 'clean'")) - return - } - - log.Printf("PDF export requested: lang=%s, length=%s, icons=%s, version=%s", lang, length, icons, version) - - // Load CV data to get name for filename - cv, err := cvmodel.LoadCV(lang) - if err != nil { - HandleError(w, r, DataLoadError(err, "CV")) - return - } - - // Prepare cookies to set preferences - cookies := map[string]string{ - "cv-length": length, - "cv-icons": icons, - "cv-language": lang, - } - - // Set theme cookie based on version parameter - if version == "clean" { - cookies["cv-theme"] = "clean" - } else { - cookies["cv-theme"] = "default" - } - - // CRITICAL: ALWAYS force light mode for PDF generation (print-friendly) - // This ensures PDFs are NEVER generated in dark mode, regardless of user's preference - cookies["color-theme"] = "light" - - // Construct URL for PDF generation (navigate to home page) - targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang) - - // Determine render mode based on version parameter - // Clean version: use @media print CSS (print-friendly, no sidebars) - // Extended version: use @media screen CSS (full layout with sidebars) - var renderMode pdf.RenderMode - if version == "clean" { - renderMode = pdf.RenderModePrint - } else { - renderMode = pdf.RenderModeScreen - } - - // Generate PDF with cookies and appropriate render mode - ctx := r.Context() - pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, renderMode) - if err != nil { - log.Printf("PDF generation failed: %v", err) - HandleError(w, r, InternalError(err)) - return - } - - // Generate filename based on parameters - // Format: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf - // Note: {version} is OMITTED when it's "clean" - // Examples: - // - cv-short-jamr-2025-es.pdf (clean version, no skills) - // - cv-short-with_skills-jamr-2025-es.pdf (with skills sidebar) - // - cv-long-jamr-2025-en.pdf (clean version, no skills) - // - cv-long-with_skills-jamr-2025-en.pdf (with skills sidebar) - - // Generate initials from name - nameParts := strings.Fields(cv.Personal.Name) - initials := "" - for _, part := range nameParts { - if len(part) > 0 { - // Take first letter of each name part - initials += string([]rune(part)[0]) - } - } - initials = strings.ToLower(initials) - - // Get current year - currentYear := time.Now().Year() - - // Build filename: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf - // Omit version if it's "clean" - // Replace underscores with hyphens in version for filename (with_skills → with-skills) - var filename string - if version == "clean" { - filename = fmt.Sprintf("cv-%s-%s-%d-%s.pdf", length, initials, currentYear, lang) - } else { - versionForFilename := strings.ReplaceAll(version, "_", "-") - filename = fmt.Sprintf("cv-%s-%s-%s-%d-%s.pdf", length, versionForFilename, initials, currentYear, lang) - } - - // Set response headers - w.Header().Set("Content-Type", "application/pdf") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData))) - - // Write PDF data - if _, err := w.Write(pdfData); err != nil { - log.Printf("Error writing PDF response: %v", err) - return - } - - log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData)) -} - -// splitSkills splits skill categories between left (page 1) and right (page 2) sidebars -// Each category explicitly specifies which sidebar it belongs to via the "sidebar" field -func splitSkills(skills []cvmodel.SkillCategory) (left, right []cvmodel.SkillCategory) { - if len(skills) == 0 { - return nil, nil - } - - // Filter by sidebar field - for _, skill := range skills { - if skill.Sidebar == "right" { - right = append(right, skill) - } else { - // Default to left if not specified or if set to "left" - left = append(left, skill) - } - } - - return left, right -} - -// calculateYearsOfExperience calculates years of experience since April 1, 2005 -// This matches the original React implementation that calculated from 01/04/2005 -func calculateYearsOfExperience() int { - // First day at work: April 1, 2005 - firstDay := time.Date(2005, time.April, 1, 9, 0, 0, 0, time.UTC) - - // Current date - now := time.Now() - - // Calculate the difference in years - years := now.Year() - firstDay.Year() - - // Adjust if we haven't reached the anniversary this year yet - if now.Month() < firstDay.Month() || - (now.Month() == firstDay.Month() && now.Day() < firstDay.Day()) { - years-- - } - - return years -} - -// calculateDuration calculates the duration between two dates in years and months -// Date format expected: "YYYY-MM" (e.g., "2021-01") -// Returns a formatted string like "3 years 6 months" or "6 months" -func calculateDuration(startDate, endDate string, current bool, lang string) string { - // Parse start date - start, err := time.Parse("2006-01", startDate) - if err != nil { - return "" - } - - // Determine end date - var end time.Time - if current { - end = time.Now() - } else { - end, err = time.Parse("2006-01", endDate) - if err != nil { - return "" - } - } - - // Calculate total months - totalMonths := (end.Year()-start.Year())*12 + int(end.Month()-start.Month()) - - // If end date is before start date, return empty - if totalMonths < 0 { - return "" - } - - years := totalMonths / 12 - months := totalMonths % 12 - - // Format the duration string based on language - var result string - if lang == "es" { - if years > 0 && months > 0 { - yearStr := "años" - if years == 1 { - yearStr = "año" - } - monthStr := "meses" - if months == 1 { - monthStr = "mes" - } - result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) - } else if years > 0 { - yearStr := "años" - if years == 1 { - yearStr = "año" - } - result = fmt.Sprintf("(%d %s)", years, yearStr) - } else { - monthStr := "meses" - if months == 1 { - monthStr = "mes" - } - result = fmt.Sprintf("(%d %s)", months, monthStr) - } - } else { - if years > 0 && months > 0 { - yearStr := "years" - if years == 1 { - yearStr = "year" - } - monthStr := "months" - if months == 1 { - monthStr = "month" - } - result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) - } else if years > 0 { - yearStr := "years" - if years == 1 { - yearStr = "year" - } - result = fmt.Sprintf("(%d %s)", years, yearStr) - } else { - monthStr := "months" - if months == 1 { - monthStr = "month" - } - result = fmt.Sprintf("(%d %s)", months, monthStr) - } - } - - return result -} - -// processProjectDates calculates dynamic dates for projects -// If a project has a gitRepoUrl, it fetches the first commit date -// For current projects, it sets the current system date -func processProjectDates(project *cvmodel.Project, lang string) { - now := time.Now() - - // Set dynamic current date for ongoing projects - if project.Current { - if lang == "es" { - project.DynamicDate = "Presente" - } else { - project.DynamicDate = "Present" - } - } - - // If project has a git repository URL, fetch the first commit date - if project.GitRepoUrl != "" { - commitDate := getGitRepoFirstCommitDate(project.GitRepoUrl) - if commitDate != "" { - project.ComputedStartDate = commitDate - } - } - - // If no computed date and no static date, use current date for current projects - if project.ComputedStartDate == "" && project.StartDate == "" && project.Current { - project.ComputedStartDate = now.Format("2006-01") - } - - // If we have a computed date but no static date, use the computed one - if project.ComputedStartDate != "" && project.StartDate == "" { - project.StartDate = project.ComputedStartDate - } -} - -// findProjectRoot finds the project root directory -// It looks for .git directory walking up the directory tree -func findProjectRoot() (string, error) { - // Start from current working directory - cwd, err := os.Getwd() - if err != nil { - return "", err - } - - // Walk up the directory tree looking for .git - dir := cwd - for { - gitPath := filepath.Join(dir, ".git") - if info, err := os.Stat(gitPath); err == nil && info.IsDir() { - // Found .git directory - this is the project root - return dir, nil - } - - // Move up one directory - parent := filepath.Dir(dir) - if parent == dir { - // Reached root directory without finding .git - // Fall back to current working directory - return cwd, nil - } - dir = parent - } -} - -// validateRepoPath validates that a repository path is safe to use -// Security: Prevents path traversal and command injection attacks -// Only allows paths within the project directory -func validateRepoPath(path string) error { - // Resolve to absolute path to prevent path traversal - absPath, err := filepath.Abs(path) - if err != nil { - return fmt.Errorf("invalid path: %w", err) - } - - // Get project root directory - find the git repo root - // This ensures the validation works regardless of where code runs from - projectRoot, err := findProjectRoot() - if err != nil { - return fmt.Errorf("cannot determine project root: %w", err) - } - - // Security check: Only allow paths within project directory - // This prevents malicious paths like "../../../etc/passwd" - if !strings.HasPrefix(absPath, projectRoot) { - return fmt.Errorf("repository path outside project directory: %s", path) - } - - // Verify path exists and is a directory - info, err := os.Stat(absPath) - if err != nil { - return fmt.Errorf("path does not exist: %w", err) - } - if !info.IsDir() { - return fmt.Errorf("path is not a directory: %s", path) - } - - return nil -} - -// getGitRepoFirstCommitDate fetches the first commit date from a git repository -// Supports local git repository paths -// Security: Validates path and uses timeout to prevent hanging -func getGitRepoFirstCommitDate(repoPath string) string { - // Security: Validate repository path before executing git command - if err := validateRepoPath(repoPath); err != nil { - log.Printf("Security: Rejected git operation for invalid path %s: %v", repoPath, err) - return "" - } - - // Security: Add timeout context to prevent hanging - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Execute git command with timeout protection - // Using CommandContext for automatic cancellation on timeout - cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m") - - output, err := cmd.Output() - if err != nil { - // Log error but don't expose details to prevent information disclosure - log.Printf("Git command failed for path %s: %v", repoPath, err) - return "" - } - - // Parse the output to get the first commit date - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(lines) == 0 { - return "" - } - - // Extract YYYY-MM from the first commit timestamp - // Format of output: "2024-06-15 10:30:45 +0200" - firstLine := lines[0] - parts := strings.Fields(firstLine) - if len(parts) > 0 { - datePart := parts[0] // "2024-06-15" - dateParts := strings.Split(datePart, "-") - if len(dateParts) >= 2 { - return dateParts[0] + "-" + dateParts[1] // "2024-06" - } - } - - return "" -} - -// ============================================================================== -// HTMX ENDPOINTS - Phase 2 -// ============================================================================== - -// prepareTemplateData prepares common template data used across handlers -func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) { - // Load CV data - cv, err := cvmodel.LoadCV(lang) - if err != nil { - return nil, err - } - - // Load UI translations - ui, err := uimodel.LoadUI(lang) - if err != nil { - return nil, err - } - - // Calculate duration for each experience - for i := range cv.Experience { - cv.Experience[i].Duration = calculateDuration( - cv.Experience[i].StartDate, - cv.Experience[i].EndDate, - cv.Experience[i].Current, - lang, - ) - } - - // Process projects for dynamic dates - for i := range cv.Projects { - processProjectDates(&cv.Projects[i], lang) - } - - // Split skills between left and right sidebars - skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) - - // Calculate years of experience - yearsOfExperience := calculateYearsOfExperience() - - // Get current year - currentYear := time.Now().Year() - - // Prepare template data - data := map[string]interface{}{ - "CV": cv, - "UI": ui, - "Lang": lang, - "SkillsLeft": skillsLeft, - "SkillsRight": skillsRight, - "YearsOfExperience": yearsOfExperience, - "CurrentYear": currentYear, - "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), - "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", - "AlternateES": "https://juan.andres.morenorub.io/?lang=es", - } - - return data, nil -} - -// getPreferenceCookie gets a preference cookie value, returns default if not found -func getPreferenceCookie(r *http.Request, name string, defaultValue string) string { - cookie, err := r.Cookie(name) - if err != nil { - return defaultValue - } - return cookie.Value -} - -// setPreferenceCookie sets a preference cookie (1 year expiry) -func setPreferenceCookie(w http.ResponseWriter, name string, value string) { - http.SetCookie(w, &http.Cookie{ - Name: name, - Value: value, - Path: "/", - MaxAge: 365 * 24 * 60 * 60, // 1 year - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Secure: false, // Set to true in production with HTTPS - }) -} - -// ToggleLength handles CV length toggle (short/long) using atomic out-of-band swaps -func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Get current state - currentLength := getPreferenceCookie(r, "cv-length", "short") - - // Migrate old value if needed - if currentLength == "extended" { - currentLength = "long" - } - - // Toggle state - newLength := "long" - if currentLength == "long" { - newLength = "short" - } - - // Save new state - setPreferenceCookie(w, "cv-length", newLength) - - // Get language - lang := r.URL.Query().Get("lang") - if lang == "" { - lang = getPreferenceCookie(r, "cv-language", "en") - } - - // Prepare template data with length state - cvLengthClass := "cv-short" - if newLength == "long" { - cvLengthClass = "cv-long" - } - - data := map[string]interface{}{ - "Lang": lang, - "CVLengthClass": cvLengthClass, - } - - // Render length-toggle template with out-of-band swaps - tmpl, err := h.templates.Render("length-toggle.html") - if err != nil { - HandleError(w, r, TemplateError(err, "length-toggle.html")) - return - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - HandleError(w, r, TemplateError(err, "length-toggle.html")) - return - } -} - -// ToggleIcons handles icon visibility toggle using atomic out-of-band swaps -func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Get current state - currentIcons := getPreferenceCookie(r, "cv-icons", "show") - - // Migrate old values if needed - switch currentIcons { - case "true": - currentIcons = "show" - case "false": - currentIcons = "hide" - } - - // Toggle state - newIcons := "hide" - if currentIcons == "hide" { - newIcons = "show" - } - - // Save new state - setPreferenceCookie(w, "cv-icons", newIcons) - - // Get language - lang := r.URL.Query().Get("lang") - if lang == "" { - lang = getPreferenceCookie(r, "cv-language", "en") - } - - // Prepare template data with logo state - data := map[string]interface{}{ - "Lang": lang, - "ShowIcons": (newIcons == "show"), - } - - // Render logo-toggle template with out-of-band swaps - tmpl, err := h.templates.Render("logo-toggle.html") - if err != nil { - HandleError(w, r, TemplateError(err, "logo-toggle.html")) - return - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - HandleError(w, r, TemplateError(err, "logo-toggle.html")) - return - } -} - -// SwitchLanguage handles language switching with atomic updates -// Uses HTMX out-of-band swaps to update both the language selector buttons -// and all CV content wrappers in a single response -func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) { - // Get language from query parameter - lang := r.URL.Query().Get("lang") - if lang == "" { - lang = "en" - } - - // Validate language - if lang != "en" && lang != "es" { - HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) - return - } - - // Save language preference - setPreferenceCookie(w, "cv-language", lang) - - // Prepare template data - data, err := h.prepareTemplateData(lang) - if err != nil { - HandleError(w, r, DataLoadError(err, "CV")) - return - } - - // Preserve current length and logo preferences - cvLength := getPreferenceCookie(r, "cv-length", "short") - cvIcons := getPreferenceCookie(r, "cv-icons", "show") - cvTheme := getPreferenceCookie(r, "cv-theme", "default") - - // Add preferences to data - if cvLength == "long" { - data["CVLengthClass"] = "cv-long" - } else { - data["CVLengthClass"] = "cv-short" - } - data["ShowIcons"] = (cvIcons == "show") - data["ThemeClean"] = (cvTheme == "clean") - - // Render language-switch template with out-of-band swaps - tmpl, err := h.templates.Render("language-switch.html") - if err != nil { - HandleError(w, r, TemplateError(err, "language-switch.html")) - return - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - HandleError(w, r, TemplateError(err, "language-switch.html")) - return - } -} - -// ToggleTheme handles theme toggle (default/clean) using atomic out-of-band swaps -func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Get current state - currentTheme := getPreferenceCookie(r, "cv-theme", "default") - - // Toggle state - newTheme := "clean" - if currentTheme == "clean" { - newTheme = "default" - } - - // Save new state - setPreferenceCookie(w, "cv-theme", newTheme) - - // Get language - lang := r.URL.Query().Get("lang") - if lang == "" { - lang = getPreferenceCookie(r, "cv-language", "en") - } - - // Prepare template data with theme state - data := map[string]interface{}{ - "Lang": lang, - "ThemeClean": (newTheme == "clean"), - } - - // Render theme-toggle template with out-of-band swaps - tmpl, err := h.templates.Render("theme-toggle.html") - if err != nil { - HandleError(w, r, TemplateError(err, "theme-toggle.html")) - return - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - HandleError(w, r, TemplateError(err, "theme-toggle.html")) - return - } -} diff --git a/internal/handlers/cv_helpers.go b/internal/handlers/cv_helpers.go new file mode 100644 index 0000000..ead3607 --- /dev/null +++ b/internal/handlers/cv_helpers.go @@ -0,0 +1,385 @@ +package handlers + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + cvmodel "github.com/juanatsap/cv-site/internal/models/cv" + uimodel "github.com/juanatsap/cv-site/internal/models/ui" +) + +// ============================================================================== +// SKILLS HELPERS +// ============================================================================== + +// splitSkills splits skill categories between left (page 1) and right (page 2) sidebars +// Each category explicitly specifies which sidebar it belongs to via the "sidebar" field +func splitSkills(skills []cvmodel.SkillCategory) (left, right []cvmodel.SkillCategory) { + if len(skills) == 0 { + return nil, nil + } + + // Filter by sidebar field + for _, skill := range skills { + if skill.Sidebar == "right" { + right = append(right, skill) + } else { + // Default to left if not specified or if set to "left" + left = append(left, skill) + } + } + + return left, right +} + +// ============================================================================== +// DATE/DURATION HELPERS +// ============================================================================== + +// calculateYearsOfExperience calculates years of experience since April 1, 2005 +// This matches the original React implementation that calculated from 01/04/2005 +func calculateYearsOfExperience() int { + // First day at work: April 1, 2005 + firstDay := time.Date(2005, time.April, 1, 9, 0, 0, 0, time.UTC) + + // Current date + now := time.Now() + + // Calculate the difference in years + years := now.Year() - firstDay.Year() + + // Adjust if we haven't reached the anniversary this year yet + if now.Month() < firstDay.Month() || + (now.Month() == firstDay.Month() && now.Day() < firstDay.Day()) { + years-- + } + + return years +} + +// calculateDuration calculates the duration between two dates in years and months +// Date format expected: "YYYY-MM" (e.g., "2021-01") +// Returns a formatted string like "3 years 6 months" or "6 months" +func calculateDuration(startDate, endDate string, current bool, lang string) string { + // Parse start date + start, err := time.Parse("2006-01", startDate) + if err != nil { + return "" + } + + // Determine end date + var end time.Time + if current { + end = time.Now() + } else { + end, err = time.Parse("2006-01", endDate) + if err != nil { + return "" + } + } + + // Calculate total months + totalMonths := (end.Year()-start.Year())*12 + int(end.Month()-start.Month()) + + // If end date is before start date, return empty + if totalMonths < 0 { + return "" + } + + years := totalMonths / 12 + months := totalMonths % 12 + + // Format the duration string based on language + var result string + if lang == "es" { + if years > 0 && months > 0 { + yearStr := "años" + if years == 1 { + yearStr = "año" + } + monthStr := "meses" + if months == 1 { + monthStr = "mes" + } + result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) + } else if years > 0 { + yearStr := "años" + if years == 1 { + yearStr = "año" + } + result = fmt.Sprintf("(%d %s)", years, yearStr) + } else { + monthStr := "meses" + if months == 1 { + monthStr = "mes" + } + result = fmt.Sprintf("(%d %s)", months, monthStr) + } + } else { + if years > 0 && months > 0 { + yearStr := "years" + if years == 1 { + yearStr = "year" + } + monthStr := "months" + if months == 1 { + monthStr = "month" + } + result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) + } else if years > 0 { + yearStr := "years" + if years == 1 { + yearStr = "year" + } + result = fmt.Sprintf("(%d %s)", years, yearStr) + } else { + monthStr := "months" + if months == 1 { + monthStr = "month" + } + result = fmt.Sprintf("(%d %s)", months, monthStr) + } + } + + return result +} + +// processProjectDates calculates dynamic dates for projects +// If a project has a gitRepoUrl, it fetches the first commit date +// For current projects, it sets the current system date +func processProjectDates(project *cvmodel.Project, lang string) { + now := time.Now() + + // Set dynamic current date for ongoing projects + if project.Current { + if lang == "es" { + project.DynamicDate = "Presente" + } else { + project.DynamicDate = "Present" + } + } + + // If project has a git repository URL, fetch the first commit date + if project.GitRepoUrl != "" { + commitDate := getGitRepoFirstCommitDate(project.GitRepoUrl) + if commitDate != "" { + project.ComputedStartDate = commitDate + } + } + + // If no computed date and no static date, use current date for current projects + if project.ComputedStartDate == "" && project.StartDate == "" && project.Current { + project.ComputedStartDate = now.Format("2006-01") + } + + // If we have a computed date but no static date, use the computed one + if project.ComputedStartDate != "" && project.StartDate == "" { + project.StartDate = project.ComputedStartDate + } +} + +// ============================================================================== +// GIT HELPERS +// ============================================================================== + +// findProjectRoot finds the project root directory +// It looks for .git directory walking up the directory tree +func findProjectRoot() (string, error) { + // Start from current working directory + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + // Walk up the directory tree looking for .git + dir := cwd + for { + gitPath := filepath.Join(dir, ".git") + if info, err := os.Stat(gitPath); err == nil && info.IsDir() { + // Found .git directory - this is the project root + return dir, nil + } + + // Move up one directory + parent := filepath.Dir(dir) + if parent == dir { + // Reached root directory without finding .git + // Fall back to current working directory + return cwd, nil + } + dir = parent + } +} + +// validateRepoPath validates that a repository path is safe to use +// Security: Prevents path traversal and command injection attacks +// Only allows paths within the project directory +func validateRepoPath(path string) error { + // Resolve to absolute path to prevent path traversal + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + + // Get project root directory - find the git repo root + // This ensures the validation works regardless of where code runs from + projectRoot, err := findProjectRoot() + if err != nil { + return fmt.Errorf("cannot determine project root: %w", err) + } + + // Security check: Only allow paths within project directory + // This prevents malicious paths like "../../../etc/passwd" + if !strings.HasPrefix(absPath, projectRoot) { + return fmt.Errorf("repository path outside project directory: %s", path) + } + + // Verify path exists and is a directory + info, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("path does not exist: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("path is not a directory: %s", path) + } + + return nil +} + +// getGitRepoFirstCommitDate fetches the first commit date from a git repository +// Supports local git repository paths +// Security: Validates path and uses timeout to prevent hanging +func getGitRepoFirstCommitDate(repoPath string) string { + // Security: Validate repository path before executing git command + if err := validateRepoPath(repoPath); err != nil { + log.Printf("Security: Rejected git operation for invalid path %s: %v", repoPath, err) + return "" + } + + // Security: Add timeout context to prevent hanging + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Execute git command with timeout protection + // Using CommandContext for automatic cancellation on timeout + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m") + + output, err := cmd.Output() + if err != nil { + // Log error but don't expose details to prevent information disclosure + log.Printf("Git command failed for path %s: %v", repoPath, err) + return "" + } + + // Parse the output to get the first commit date + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 0 { + return "" + } + + // Extract YYYY-MM from the first commit timestamp + // Format of output: "2024-06-15 10:30:45 +0200" + firstLine := lines[0] + parts := strings.Fields(firstLine) + if len(parts) > 0 { + datePart := parts[0] // "2024-06-15" + dateParts := strings.Split(datePart, "-") + if len(dateParts) >= 2 { + return dateParts[0] + "-" + dateParts[1] // "2024-06" + } + } + + return "" +} + +// ============================================================================== +// TEMPLATE DATA PREPARATION +// ============================================================================== + +// prepareTemplateData prepares common template data used across handlers +func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) { + // Load CV data + cv, err := cvmodel.LoadCV(lang) + if err != nil { + return nil, err + } + + // Load UI translations + ui, err := uimodel.LoadUI(lang) + if err != nil { + return nil, err + } + + // Calculate duration for each experience + for i := range cv.Experience { + cv.Experience[i].Duration = calculateDuration( + cv.Experience[i].StartDate, + cv.Experience[i].EndDate, + cv.Experience[i].Current, + lang, + ) + } + + // Process projects for dynamic dates + for i := range cv.Projects { + processProjectDates(&cv.Projects[i], lang) + } + + // Split skills between left and right sidebars + skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) + + // Calculate years of experience + yearsOfExperience := calculateYearsOfExperience() + + // Get current year + currentYear := time.Now().Year() + + // Prepare template data + data := map[string]interface{}{ + "CV": cv, + "UI": ui, + "Lang": lang, + "SkillsLeft": skillsLeft, + "SkillsRight": skillsRight, + "YearsOfExperience": yearsOfExperience, + "CurrentYear": currentYear, + "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), + "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", + "AlternateES": "https://juan.andres.morenorub.io/?lang=es", + } + + return data, nil +} + +// ============================================================================== +// COOKIE HELPERS +// ============================================================================== + +// getPreferenceCookie gets a preference cookie value, returns default if not found +func getPreferenceCookie(r *http.Request, name string, defaultValue string) string { + cookie, err := r.Cookie(name) + if err != nil { + return defaultValue + } + return cookie.Value +} + +// setPreferenceCookie sets a preference cookie (1 year expiry) +func setPreferenceCookie(w http.ResponseWriter, name string, value string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + MaxAge: 365 * 24 * 60 * 60, // 1 year + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: false, // Set to true in production with HTTPS + }) +} diff --git a/internal/handlers/cv_htmx.go b/internal/handlers/cv_htmx.go new file mode 100644 index 0000000..6005e36 --- /dev/null +++ b/internal/handlers/cv_htmx.go @@ -0,0 +1,218 @@ +package handlers + +import ( + "net/http" +) + +// ============================================================================== +// HTMX TOGGLE HANDLERS +// These handlers manage user preferences (length, icons, language, theme) +// using atomic out-of-band swaps for a smooth UX +// ============================================================================== + +// ToggleLength handles CV length toggle (short/long) using atomic out-of-band swaps +func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get current state + currentLength := getPreferenceCookie(r, "cv-length", "short") + + // Migrate old value if needed + if currentLength == "extended" { + currentLength = "long" + } + + // Toggle state + newLength := "long" + if currentLength == "long" { + newLength = "short" + } + + // Save new state + setPreferenceCookie(w, "cv-length", newLength) + + // Get language + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = getPreferenceCookie(r, "cv-language", "en") + } + + // Prepare template data with length state + cvLengthClass := "cv-short" + if newLength == "long" { + cvLengthClass = "cv-long" + } + + data := map[string]interface{}{ + "Lang": lang, + "CVLengthClass": cvLengthClass, + } + + // Render length-toggle template with out-of-band swaps + tmpl, err := h.templates.Render("length-toggle.html") + if err != nil { + HandleError(w, r, TemplateError(err, "length-toggle.html")) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + HandleError(w, r, TemplateError(err, "length-toggle.html")) + return + } +} + +// ToggleIcons handles icon visibility toggle using atomic out-of-band swaps +func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get current state + currentIcons := getPreferenceCookie(r, "cv-icons", "show") + + // Migrate old values if needed + switch currentIcons { + case "true": + currentIcons = "show" + case "false": + currentIcons = "hide" + } + + // Toggle state + newIcons := "hide" + if currentIcons == "hide" { + newIcons = "show" + } + + // Save new state + setPreferenceCookie(w, "cv-icons", newIcons) + + // Get language + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = getPreferenceCookie(r, "cv-language", "en") + } + + // Prepare template data with logo state + data := map[string]interface{}{ + "Lang": lang, + "ShowIcons": (newIcons == "show"), + } + + // Render logo-toggle template with out-of-band swaps + tmpl, err := h.templates.Render("logo-toggle.html") + if err != nil { + HandleError(w, r, TemplateError(err, "logo-toggle.html")) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + HandleError(w, r, TemplateError(err, "logo-toggle.html")) + return + } +} + +// SwitchLanguage handles language switching with atomic updates +// Uses HTMX out-of-band swaps to update both the language selector buttons +// and all CV content wrappers in a single response +func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) { + // Get language from query parameter + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = "en" + } + + // Validate language + if lang != "en" && lang != "es" { + HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) + return + } + + // Save language preference + setPreferenceCookie(w, "cv-language", lang) + + // Prepare template data + data, err := h.prepareTemplateData(lang) + if err != nil { + HandleError(w, r, DataLoadError(err, "CV")) + return + } + + // Preserve current length and logo preferences + cvLength := getPreferenceCookie(r, "cv-length", "short") + cvIcons := getPreferenceCookie(r, "cv-icons", "show") + cvTheme := getPreferenceCookie(r, "cv-theme", "default") + + // Add preferences to data + if cvLength == "long" { + data["CVLengthClass"] = "cv-long" + } else { + data["CVLengthClass"] = "cv-short" + } + data["ShowIcons"] = (cvIcons == "show") + data["ThemeClean"] = (cvTheme == "clean") + + // Render language-switch template with out-of-band swaps + tmpl, err := h.templates.Render("language-switch.html") + if err != nil { + HandleError(w, r, TemplateError(err, "language-switch.html")) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + HandleError(w, r, TemplateError(err, "language-switch.html")) + return + } +} + +// ToggleTheme handles theme toggle (default/clean) using atomic out-of-band swaps +func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get current state + currentTheme := getPreferenceCookie(r, "cv-theme", "default") + + // Toggle state + newTheme := "clean" + if currentTheme == "clean" { + newTheme = "default" + } + + // Save new state + setPreferenceCookie(w, "cv-theme", newTheme) + + // Get language + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = getPreferenceCookie(r, "cv-language", "en") + } + + // Prepare template data with theme state + data := map[string]interface{}{ + "Lang": lang, + "ThemeClean": (newTheme == "clean"), + } + + // Render theme-toggle template with out-of-band swaps + tmpl, err := h.templates.Render("theme-toggle.html") + if err != nil { + HandleError(w, r, TemplateError(err, "theme-toggle.html")) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + HandleError(w, r, TemplateError(err, "theme-toggle.html")) + return + } +} diff --git a/internal/handlers/cv_pages.go b/internal/handlers/cv_pages.go new file mode 100644 index 0000000..cd23eed --- /dev/null +++ b/internal/handlers/cv_pages.go @@ -0,0 +1,290 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "path/filepath" + "strings" + "time" + + cvmodel "github.com/juanatsap/cv-site/internal/models/cv" + uimodel "github.com/juanatsap/cv-site/internal/models/ui" + "github.com/juanatsap/cv-site/internal/pdf" +) + +// ============================================================================== +// PAGE HANDLERS +// These handlers render full HTML pages or page sections +// ============================================================================== + +// Home renders the full CV page +func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { + // Check if this is a shortcut URL request (cv-jamr-{year}-{lang}.pdf) + if strings.HasPrefix(r.URL.Path, "/cv-jamr-") && strings.HasSuffix(r.URL.Path, ".pdf") { + h.DefaultCVShortcut(w, r) + return + } + + // Get language from query parameter, default to English + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = "en" + } + + // Validate language + if lang != "en" && lang != "es" { + HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) + return + } + + // Load CV data + cv, err := cvmodel.LoadCV(lang) + if err != nil { + HandleError(w, r, DataLoadError(err, "CV")) + return + } + + // Load UI translations + ui, err := uimodel.LoadUI(lang) + if err != nil { + HandleError(w, r, DataLoadError(err, "UI")) + return + } + + // Calculate duration for each experience + for i := range cv.Experience { + cv.Experience[i].Duration = calculateDuration( + cv.Experience[i].StartDate, + cv.Experience[i].EndDate, + cv.Experience[i].Current, + lang, + ) + } + + // Process projects for dynamic dates + for i := range cv.Projects { + processProjectDates(&cv.Projects[i], lang) + } + + // Split skills between left and right sidebars + skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) + + // Calculate years of experience + yearsOfExperience := calculateYearsOfExperience() + + // Get current year + currentYear := time.Now().Year() + + // Read user preferences from cookies + cvLength := getPreferenceCookie(r, "cv-length", "short") + cvIcons := getPreferenceCookie(r, "cv-icons", "show") + cvTheme := getPreferenceCookie(r, "cv-theme", "default") + + // Migrate old preference values to new ones (one-time auto-migration) + if cvLength == "extended" { + cvLength = "long" + setPreferenceCookie(w, "cv-length", "long") + } + switch cvIcons { + case "true": + cvIcons = "show" + setPreferenceCookie(w, "cv-icons", "show") + case "false": + cvIcons = "hide" + setPreferenceCookie(w, "cv-icons", "hide") + } + + // Prepare CV length class + cvLengthClass := "cv-short" + if cvLength == "long" { + cvLengthClass = "cv-long" + } + + // Prepare template data + data := map[string]interface{}{ + "CV": cv, + "UI": ui, + "Lang": lang, + "SkillsLeft": skillsLeft, + "SkillsRight": skillsRight, + "YearsOfExperience": yearsOfExperience, + "CurrentYear": currentYear, + "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), + "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", + "AlternateES": "https://juan.andres.morenorub.io/?lang=es", + "CVLengthClass": cvLengthClass, + "ShowIcons": (cvIcons == "show"), + "ThemeClean": (cvTheme == "clean"), + } + + // Render template + tmpl, err := h.templates.Render("index.html") + if err != nil { + HandleError(w, r, TemplateError(err, "index.html")) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + HandleError(w, r, TemplateError(err, "index.html")) + return + } +} + +// CVContent renders just the CV content for HTMX swaps +func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) { + // Get language from query parameter + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = "en" + } + + // Validate language + if lang != "en" && lang != "es" { + HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) + return + } + + // Load CV data + cv, err := cvmodel.LoadCV(lang) + if err != nil { + HandleError(w, r, DataLoadError(err, "CV")) + return + } + + // Load UI translations + ui, err := uimodel.LoadUI(lang) + if err != nil { + HandleError(w, r, DataLoadError(err, "UI")) + return + } + + // Calculate duration for each experience + for i := range cv.Experience { + cv.Experience[i].Duration = calculateDuration( + cv.Experience[i].StartDate, + cv.Experience[i].EndDate, + cv.Experience[i].Current, + lang, + ) + } + + // Process projects for dynamic dates + for i := range cv.Projects { + processProjectDates(&cv.Projects[i], lang) + } + + // Split skills between left and right sidebars + skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) + + // Calculate years of experience + yearsOfExperience := calculateYearsOfExperience() + + // Get current year + currentYear := time.Now().Year() + + // Prepare template data + data := map[string]interface{}{ + "CV": cv, + "UI": ui, + "Lang": lang, + "SkillsLeft": skillsLeft, + "SkillsRight": skillsRight, + "YearsOfExperience": yearsOfExperience, + "CurrentYear": currentYear, + "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), + "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", + "AlternateES": "https://juan.andres.morenorub.io/?lang=es", + } + + // Render template + tmpl, err := h.templates.Render("cv-content.html") + if err != nil { + HandleError(w, r, TemplateError(err, "cv-content.html")) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + HandleError(w, r, TemplateError(err, "cv-content.html")) + return + } +} + +// DefaultCVShortcut handles shortcut URLs for default CV downloads +// Pattern: /cv-jamr-{year}-{lang}.pdf (e.g., /cv-jamr-2025-en.pdf) +// Validates year matches current year and redirects to default PDF export +func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) { + // Extract path (e.g., "/cv-jamr-2025-en.pdf") + path := r.URL.Path + log.Printf("DefaultCVShortcut called with path: %s", path) + + // Parse filename pattern: cv-jamr-{year}-{lang}.pdf + parts := strings.Split(strings.TrimPrefix(path, "/"), "-") + if len(parts) != 4 { + http.NotFound(w, r) + return + } + + // Extract year and language + yearStr := parts[2] + langWithExt := parts[3] // "en.pdf" or "es.pdf" + lang := strings.TrimSuffix(langWithExt, ".pdf") + + // Validate language + if lang != "en" && lang != "es" { + http.NotFound(w, r) + return + } + + // Validate year matches current year + currentYear := fmt.Sprintf("%d", time.Now().Year()) + if yearStr != currentYear { + // Return 404 if year doesn't match (old or future URLs) + log.Printf("Invalid year in shortcut URL: %s (current: %s)", yearStr, currentYear) + http.NotFound(w, r) + return + } + + // Generate PDF directly instead of redirecting + // This ensures the Content-Disposition filename is respected by browsers + log.Printf("Shortcut URL: %s → generating PDF (short + with_skills)", path) + + // Prepare cookies for PDF generation (short, with_skills, light mode) + cookies := map[string]string{ + "cv-length": "short", + "cv-icons": "show", + "cv-language": lang, + "cv-theme": "default", // with_skills = default theme + "color-theme": "light", // Always light for PDFs + } + + // Construct URL for PDF generation + targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang) + + // Generate PDF with screen render mode (for sidebar layout) + ctx := r.Context() + pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, pdf.RenderModeScreen) + if err != nil { + log.Printf("PDF generation failed for shortcut URL: %v", err) + HandleError(w, r, InternalError(err)) + return + } + + // Use the shortcut filename directly (simple, user-friendly) + filename := filepath.Base(path) // cv-jamr-2025-en.pdf + + // Set response headers with shortcut filename + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData))) + + // Write PDF data + if _, err := w.Write(pdfData); err != nil { + log.Printf("Error writing PDF response: %v", err) + return + } + + log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData)) +} diff --git a/internal/handlers/cv_pdf.go b/internal/handlers/cv_pdf.go new file mode 100644 index 0000000..325091e --- /dev/null +++ b/internal/handlers/cv_pdf.go @@ -0,0 +1,153 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + cvmodel "github.com/juanatsap/cv-site/internal/models/cv" + "github.com/juanatsap/cv-site/internal/pdf" +) + +// ============================================================================== +// PDF EXPORT HANDLER +// Handles PDF generation with customizable options (length, icons, version, theme) +// ============================================================================== + +// ExportPDF handles PDF export requests using chromedp +func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) { + // Extract and validate query parameters + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = "en" + } + if lang != "en" && lang != "es" { + HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) + return + } + + length := r.URL.Query().Get("length") + if length == "" { + length = "short" + } + if length != "short" && length != "long" { + HandleError(w, r, BadRequestError("Unsupported length. Use 'short' or 'long'")) + return + } + + icons := r.URL.Query().Get("icons") + if icons == "" { + icons = "show" + } + if icons != "show" && icons != "hide" { + HandleError(w, r, BadRequestError("Unsupported icons option. Use 'show' or 'hide'")) + return + } + + version := r.URL.Query().Get("version") + if version == "" { + version = "with_skills" + } + if version != "with_skills" && version != "clean" { + HandleError(w, r, BadRequestError("Unsupported version. Use 'with_skills' or 'clean'")) + return + } + + log.Printf("PDF export requested: lang=%s, length=%s, icons=%s, version=%s", lang, length, icons, version) + + // Load CV data to get name for filename + cv, err := cvmodel.LoadCV(lang) + if err != nil { + HandleError(w, r, DataLoadError(err, "CV")) + return + } + + // Prepare cookies to set preferences + cookies := map[string]string{ + "cv-length": length, + "cv-icons": icons, + "cv-language": lang, + } + + // Set theme cookie based on version parameter + if version == "clean" { + cookies["cv-theme"] = "clean" + } else { + cookies["cv-theme"] = "default" + } + + // CRITICAL: ALWAYS force light mode for PDF generation (print-friendly) + // This ensures PDFs are NEVER generated in dark mode, regardless of user's preference + cookies["color-theme"] = "light" + + // Construct URL for PDF generation (navigate to home page) + targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang) + + // Determine render mode based on version parameter + // Clean version: use @media print CSS (print-friendly, no sidebars) + // Extended version: use @media screen CSS (full layout with sidebars) + var renderMode pdf.RenderMode + if version == "clean" { + renderMode = pdf.RenderModePrint + } else { + renderMode = pdf.RenderModeScreen + } + + // Generate PDF with cookies and appropriate render mode + ctx := r.Context() + pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, renderMode) + if err != nil { + log.Printf("PDF generation failed: %v", err) + HandleError(w, r, InternalError(err)) + return + } + + // Generate filename based on parameters + // Format: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf + // Note: {version} is OMITTED when it's "clean" + // Examples: + // - cv-short-jamr-2025-es.pdf (clean version, no skills) + // - cv-short-with_skills-jamr-2025-es.pdf (with skills sidebar) + // - cv-long-jamr-2025-en.pdf (clean version, no skills) + // - cv-long-with_skills-jamr-2025-en.pdf (with skills sidebar) + + // Generate initials from name + nameParts := strings.Fields(cv.Personal.Name) + initials := "" + for _, part := range nameParts { + if len(part) > 0 { + // Take first letter of each name part + initials += string([]rune(part)[0]) + } + } + initials = strings.ToLower(initials) + + // Get current year + currentYear := time.Now().Year() + + // Build filename: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf + // Omit version if it's "clean" + // Replace underscores with hyphens in version for filename (with_skills → with-skills) + var filename string + if version == "clean" { + filename = fmt.Sprintf("cv-%s-%s-%d-%s.pdf", length, initials, currentYear, lang) + } else { + versionForFilename := strings.ReplaceAll(version, "_", "-") + filename = fmt.Sprintf("cv-%s-%s-%s-%d-%s.pdf", length, versionForFilename, initials, currentYear, lang) + } + + // Set response headers + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData))) + + // Write PDF data + if _, err := w.Write(pdfData); err != nil { + log.Printf("Error writing PDF response: %v", err) + return + } + + log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData)) +}