package handlers import ( "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/object" c "github.com/juanatsap/cv-site/internal/constants" cvmodel "github.com/juanatsap/cv-site/internal/models/cv" ) // ============================================================================== // 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 using go-git // 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 path, 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 (using go-git - pure Go implementation, no shell commands) // ============================================================================== // findProjectRoot finds the project root directory by looking for .git directory func findProjectRoot() (string, error) { cwd, err := os.Getwd() if err != nil { return "", err } dir := cwd for { gitPath := filepath.Join(dir, ".git") if info, err := os.Stat(gitPath); err == nil && info.IsDir() { return dir, nil } parent := filepath.Dir(dir) if parent == dir { return cwd, nil } dir = parent } } // validateRepoPath validates that a repository path is safe to use // Security: Prevents path traversal attacks by ensuring path is within project directory func validateRepoPath(path string) error { absPath, err := filepath.Abs(path) if err != nil { return fmt.Errorf("invalid path: %w", err) } projectRoot, err := findProjectRoot() if err != nil { return fmt.Errorf("cannot determine project root: %w", err) } // Security: Only allow paths within project directory if !strings.HasPrefix(absPath, projectRoot) { return fmt.Errorf("repository path outside project directory: %s", path) } 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 // Uses go-git (pure Go) - no shell command execution, eliminating injection risks func getGitRepoFirstCommitDate(repoPath string) string { // Security: Validate repository path if err := validateRepoPath(repoPath); err != nil { log.Printf("Security: Rejected git operation for invalid path %s: %v", repoPath, err) return "" } // Open the repository using go-git repo, err := git.PlainOpen(repoPath) if err != nil { log.Printf("Failed to open git repository at %s: %v", repoPath, err) return "" } // Get the commit history commitIter, err := repo.Log(&git.LogOptions{ Order: git.LogOrderCommitterTime, }) if err != nil { log.Printf("Failed to get commit log for %s: %v", repoPath, err) return "" } defer commitIter.Close() // Find the oldest commit by iterating through all commits var oldestCommit *object.Commit err = commitIter.ForEach(func(c *object.Commit) error { if oldestCommit == nil || c.Committer.When.Before(oldestCommit.Committer.When) { oldestCommit = c } return nil }) if err != nil { log.Printf("Error iterating commits for %s: %v", repoPath, err) return "" } if oldestCommit == nil { log.Printf("No commits found in repository %s", repoPath) return "" } // Return date in YYYY-MM format return oldestCommit.Committer.When.Format("2006-01") } // ============================================================================== // TEMPLATE DATA PREPARATION // ============================================================================== // prepareTemplateData prepares common template data used across handlers func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) { // Get CV data from cache cachedCV := h.dataCache.GetCV(lang) if cachedCV == nil { return nil, fmt.Errorf("CV data not found for language: %s", lang) } // Get UI translations from cache ui := h.dataCache.GetUI(lang) if ui == nil { return nil, fmt.Errorf("UI data not found for language: %s", lang) } // Create a working copy of CV to avoid mutating cached data cv := *cachedCV // Deep copy Experience slice (we modify Duration field) cv.Experience = make([]cvmodel.Experience, len(cachedCV.Experience)) copy(cv.Experience, cachedCV.Experience) // Deep copy Projects slice (we modify computed fields) cv.Projects = make([]cvmodel.Project, len(cachedCV.Projects)) copy(cv.Projects, cachedCV.Projects) // 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(cachedCV.Skills.Technical) // Calculate years of experience yearsOfExperience := calculateYearsOfExperience() // Get current year currentYear := time.Now().Year() // Check if production mode AND CSS bundle exists // This ensures graceful fallback to modular CSS if bundle not built isProduction := os.Getenv(c.EnvVarGOEnv) == c.EnvProduction if isProduction { bundlePath := filepath.Join(c.DirStatic, "dist", "bundle.min.css") if _, err := os.Stat(bundlePath); os.IsNotExist(err) { // Bundle doesn't exist, fall back to modular CSS isProduction = false } } // Prepare template data data := map[string]interface{}{ "CV": &cv, "UI": ui, "Lang": lang, "SkillsLeft": skillsLeft, "SkillsRight": skillsRight, "YearsOfExperience": yearsOfExperience, "CurrentYear": currentYear, "IsProduction": isProduction, "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", "ChatEnabled": h.chatEnabled, } return data, nil } // ============================================================================== // COOKIE HELPERS // ============================================================================== // Note: Cookie preference management is now handled client-side via JavaScript // and localStorage. Server-side cookie helpers have been removed as unused.