Files
cv-site/internal/handlers/cv_helpers.go
T
juanatsap 76d80edd7e fix: Remove unused cookie helper functions and fix desktop sidebar visibility
1. Removed unused getPreferenceCookie and setPreferenceCookie functions
   - These were flagged by golangci-lint as unused
   - Cookie preferences now handled client-side via localStorage
   - Removed unused net/http import

2. Fixed desktop sidebar accordion auto-opening
   - Updated handleLandscapeAccordions() to open accordions in desktop view (≥769px)
   - Sidebars now show content in desktop, landscape mobile, and portrait mobile
   - Only keep accordions collapsed in portrait mobile for space saving

3. Created comprehensive multi-viewport test (66-comprehensive-all-viewports-test.mjs)
   - Tests desktop (1278px), portrait mobile (375×667), landscape mobile (667×375)
   - Validates sidebars, accordion state, content visibility, AND all buttons
   - Checks button backdrop visibility in mobile views
   - Every feature now has corresponding test coverage

Fixes golangci-lint errors:
- internal/handlers/cv_helpers.go:366: func getPreferenceCookie is unused
- internal/handlers/cv_helpers.go:375: func setPreferenceCookie is unused
- internal/handlers/cv_helpers.go:7: net/http imported and not used
2025-11-25 06:00:39 +00:00

365 lines
10 KiB
Go

package handlers
import (
"context"
"fmt"
"log"
"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
// ==============================================================================
// Note: Cookie preference management is now handled client-side via JavaScript
// and localStorage. Server-side cookie helpers have been removed as unused.