package handlers import ( "context" "fmt" "log" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "github.com/juanatsap/cv-site/internal/models" "github.com/juanatsap/cv-site/internal/pdf" "github.com/juanatsap/cv-site/internal/templates" ) // CVHandler handles CV-related requests type CVHandler struct { templates *templates.Manager pdfGenerator *pdf.Generator serverAddr string } // NewCVHandler creates a new CV handler func NewCVHandler(tmpl *templates.Manager, serverAddr string) *CVHandler { return &CVHandler{ templates: tmpl, pdfGenerator: pdf.NewGenerator(30 * time.Second), serverAddr: serverAddr, } } // Home renders the full CV page func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { // 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 := models.LoadCV(lang) if err != nil { HandleError(w, r, DataLoadError(err, "CV")) return } // Load UI translations ui, err := models.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") // 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 := models.LoadCV(lang) if err != nil { HandleError(w, r, DataLoadError(err, "CV")) return } // Load UI translations ui, err := models.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 } } // ExportPDF handles PDF export requests using chromedp // TEMPORARILY DISABLED - Work in progress func (h *CVHandler) ExportPDF(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 } log.Printf("PDF export requested but temporarily disabled (redirecting to print friendly)") // Return HTML with message and redirect to print friendly message := "PDF Export - Work in Progress" body := "The PDF export feature is currently being improved. Please use the Print Friendly button instead (Ctrl+P or Cmd+P to save as PDF)." if lang == "es" { message = "Exportación PDF - En Desarrollo" body = "La función de exportación a PDF está siendo mejorada. Por favor, usa el botón Imprimir Amigable en su lugar (Ctrl+P o Cmd+P para guardar como PDF)." } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) redirectMsg := "Redirecting in 5 seconds..." if lang == "es" { redirectMsg = "Redirigiendo en 5 segundos..." } html := fmt.Sprintf(` %s
🚧

%s

%s

%s
`, lang, message, lang, message, body, redirectMsg) if _, err := w.Write([]byte(html)); err != nil { log.Printf("Error writing response: %v", err) } } // 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 []models.SkillCategory) (left, right []models.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 *models.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 := models.LoadCV(lang) if err != nil { return nil, err } // Load UI translations ui, err := models.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") // 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") // 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 } }