f5c78e6845
- Agent prompt rewritten to first person ("I worked at...", "I built...")
- Bot avatar replaced with dni-thumb.jpeg (2.6KB, 56x56 retina)
- Greeting: "Pregúntame lo que quieras sobre mi currículum"
- Underlined "mi currículum" with floating tooltip disclaimer
- Every response ends with cordial email contact invitation
- Background photos now visible in production (random per load)
- Toggle button remains dev-only
391 lines
11 KiB
Go
391 lines
11 KiB
Go
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
|
|
}
|
|
}
|
|
|
|
// Scan background photos
|
|
var bgPhotos []string
|
|
bgDir := filepath.Join(c.DirStatic, "images", "backgrounds")
|
|
entries, _ := os.ReadDir(bgDir)
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !e.IsDir() && (strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".png") || strings.HasSuffix(name, ".webp")) {
|
|
bgPhotos = append(bgPhotos, "/static/images/backgrounds/"+name)
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
"BgPhotos": bgPhotos,
|
|
}
|
|
|
|
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.
|