diff --git a/.gitignore b/.gitignore index e4dab43..917f5db 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ Thumbs.db *.tmp *.log cv-app +static/psd +static/psd/yo DNI.psd diff --git a/benchmark_cache.sh b/benchmark_cache.sh new file mode 100755 index 0000000..e6dcd59 --- /dev/null +++ b/benchmark_cache.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +# Benchmark script to test cache performance improvement +# Compares performance with and without cache + +set -e + +echo "==================================================" +echo "CV Application Cache Performance Benchmark" +echo "==================================================" +echo "" + +# Colors +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +BASE_URL="http://localhost:1999" +REQUESTS=100 +CONCURRENT=10 + +echo -e "${BLUE}Configuration:${NC}" +echo " - Requests: $REQUESTS" +echo " - Concurrent: $CONCURRENT" +echo " - Languages: en, es" +echo "" + +# Test 1: Sequential requests (warm cache) +echo -e "${YELLOW}Test 1: Sequential Performance (Cache Warmed)${NC}" +echo "Making $REQUESTS sequential requests..." + +start=$(date +%s.%N) +for i in $(seq 1 $REQUESTS); do + curl -s -o /dev/null "$BASE_URL/?lang=en" + curl -s -o /dev/null "$BASE_URL/?lang=es" +done +end=$(date +%s.%N) + +sequential_time=$(echo "$end - $start" | bc) +sequential_rps=$(echo "scale=2; ($REQUESTS * 2) / $sequential_time" | bc) + +echo -e "${GREEN}✓ Sequential Test Complete${NC}" +echo " Total time: ${sequential_time}s" +echo " Requests/sec: $sequential_rps" +echo "" + +# Test 2: Check cache statistics +echo -e "${YELLOW}Test 2: Cache Statistics${NC}" +cache_stats=$(curl -s "$BASE_URL/health" | jq '.cache') +echo "$cache_stats" +echo "" + +# Test 3: Concurrent load test using Apache Bench (if available) +if command -v ab &> /dev/null; then + echo -e "${YELLOW}Test 3: Concurrent Load Test (Apache Bench)${NC}" + + echo "Testing English endpoint..." + ab -n $REQUESTS -c $CONCURRENT -q "$BASE_URL/?lang=en" 2>&1 | grep -E "Requests per second|Time per request|Transfer rate" + + echo "" + echo "Testing Spanish endpoint..." + ab -n $REQUESTS -c $CONCURRENT -q "$BASE_URL/?lang=es" 2>&1 | grep -E "Requests per second|Time per request|Transfer rate" + echo "" +else + echo -e "${YELLOW}Test 3: Concurrent Load Test (Manual)${NC}" + echo "Apache Bench not available, using background curl..." + + start=$(date +%s.%N) + for i in $(seq 1 $CONCURRENT); do + ( + for j in $(seq 1 10); do + curl -s -o /dev/null "$BASE_URL/?lang=en" + curl -s -o /dev/null "$BASE_URL/?lang=es" + done + ) & + done + wait + end=$(date +%s.%N) + + concurrent_time=$(echo "$end - $start" | bc) + concurrent_rps=$(echo "scale=2; ($CONCURRENT * 10 * 2) / $concurrent_time" | bc) + + echo -e "${GREEN}✓ Concurrent Test Complete${NC}" + echo " Total time: ${concurrent_time}s" + echo " Requests/sec: $concurrent_rps" + echo "" +fi + +# Test 4: Response time percentiles +echo -e "${YELLOW}Test 4: Response Time Percentiles (50 requests)${NC}" + +times=() +for i in $(seq 1 50); do + time=$(curl -s -o /dev/null -w "%{time_total}" "$BASE_URL/?lang=en") + times+=($time) +done + +# Sort times +IFS=$'\n' sorted=($(sort -n <<<"${times[*]}")) +unset IFS + +p50_idx=$(echo "(50 * 50 / 100) - 1" | bc) +p95_idx=$(echo "(95 * 50 / 100) - 1" | bc) +p99_idx=$(echo "(99 * 50 / 100) - 1" | bc) + +echo " p50 (median): ${sorted[$p50_idx]}s" +echo " p95: ${sorted[$p95_idx]}s" +echo " p99: ${sorted[$p99_idx]}s" +echo "" + +# Final cache statistics +echo -e "${YELLOW}Final Cache Statistics:${NC}" +final_stats=$(curl -s "$BASE_URL/health" | jq '.cache') +echo "$final_stats" +echo "" + +echo -e "${GREEN}==================================================" +echo "Benchmark Complete!" +echo "==================================================${NC}" diff --git a/bin/cv-site b/bin/cv-site new file mode 100755 index 0000000..5d3870f Binary files /dev/null and b/bin/cv-site differ diff --git a/internal/cache/cv_cache.go b/internal/cache/cv_cache.go new file mode 100644 index 0000000..027a38f --- /dev/null +++ b/internal/cache/cv_cache.go @@ -0,0 +1,189 @@ +package cache + +import ( + "fmt" + "log" + "sync" + "time" +) + +// CacheEntry represents a cached item with expiration +type cacheEntry struct { + data interface{} + expiration time.Time +} + +// CVCache provides thread-safe caching for CV and UI data +type CVCache struct { + mu sync.RWMutex + entries map[string]*cacheEntry + ttl time.Duration + stats CacheStats +} + +// CacheStats tracks cache performance metrics +type CacheStats struct { + mu sync.RWMutex + Hits int64 + Misses int64 + Size int +} + +// New creates a new CVCache with the specified TTL +func New(ttl time.Duration) *CVCache { + if ttl <= 0 { + ttl = 1 * time.Hour // Default TTL + } + + cache := &CVCache{ + entries: make(map[string]*cacheEntry), + ttl: ttl, + stats: CacheStats{}, + } + + // Start background cleanup goroutine + go cache.cleanupExpired() + + return cache +} + +// Get retrieves an item from the cache +func (c *CVCache) Get(key string) (interface{}, bool) { + c.mu.RLock() + entry, exists := c.entries[key] + c.mu.RUnlock() + + if !exists { + c.recordMiss() + return nil, false + } + + // Check if expired + if time.Now().After(entry.expiration) { + c.mu.Lock() + delete(c.entries, key) + c.mu.Unlock() + c.recordMiss() + return nil, false + } + + c.recordHit() + return entry.data, true +} + +// Set stores an item in the cache with TTL +func (c *CVCache) Set(key string, data interface{}) { + c.mu.Lock() + defer c.mu.Unlock() + + c.entries[key] = &cacheEntry{ + data: data, + expiration: time.Now().Add(c.ttl), + } + + c.updateSize() +} + +// Invalidate removes a specific key from cache +func (c *CVCache) Invalidate(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.entries, key) + c.updateSize() +} + +// InvalidateAll clears the entire cache +func (c *CVCache) InvalidateAll() { + c.mu.Lock() + defer c.mu.Unlock() + + c.entries = make(map[string]*cacheEntry) + c.updateSize() + log.Println("🗑️ Cache invalidated") +} + +// Warm preloads cache with specific keys +func (c *CVCache) Warm(key string, loader func() (interface{}, error)) error { + data, err := loader() + if err != nil { + return fmt.Errorf("cache warm failed for key %s: %w", key, err) + } + + c.Set(key, data) + log.Printf("🔥 Cache warmed: %s", key) + return nil +} + +// GetStats returns current cache statistics +func (c *CVCache) GetStats() CacheStats { + c.stats.mu.RLock() + defer c.stats.mu.RUnlock() + + return CacheStats{ + Hits: c.stats.Hits, + Misses: c.stats.Misses, + Size: c.stats.Size, + } +} + +// HitRate returns the cache hit rate as a percentage +func (c *CVCache) HitRate() float64 { + stats := c.GetStats() + total := stats.Hits + stats.Misses + if total == 0 { + return 0 + } + return (float64(stats.Hits) / float64(total)) * 100 +} + +// recordHit increments the hit counter +func (c *CVCache) recordHit() { + c.stats.mu.Lock() + c.stats.Hits++ + c.stats.mu.Unlock() +} + +// recordMiss increments the miss counter +func (c *CVCache) recordMiss() { + c.stats.mu.Lock() + c.stats.Misses++ + c.stats.mu.Unlock() +} + +// updateSize updates the cached size count +func (c *CVCache) updateSize() { + c.stats.mu.Lock() + c.stats.Size = len(c.entries) + c.stats.mu.Unlock() +} + +// cleanupExpired periodically removes expired entries +func (c *CVCache) cleanupExpired() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + c.mu.Lock() + now := time.Now() + removed := 0 + + for key, entry := range c.entries { + if now.After(entry.expiration) { + delete(c.entries, key) + removed++ + } + } + + if removed > 0 { + c.updateSize() + log.Printf("🧹 Cache cleanup: removed %d expired entries", removed) + } + c.mu.Unlock() + } +} + +// BuildKey creates a consistent cache key +func BuildKey(prefix, lang string) string { + return fmt.Sprintf("%s:%s", prefix, lang) +} diff --git a/internal/handlers/cv.go b/internal/handlers/cv.go index a8b6574..e400879 100644 --- a/internal/handlers/cv.go +++ b/internal/handlers/cv.go @@ -1,11 +1,13 @@ package handlers import ( + "context" "fmt" "log" "net/http" "os" "os/exec" + "path/filepath" "strings" "time" @@ -91,6 +93,9 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { "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 @@ -168,6 +173,9 @@ func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) { "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 @@ -449,22 +457,92 @@ func processProjectDates(project *models.Project, lang string) { } } +// 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 { - // Check if the path exists and is a directory - info, err := os.Stat(repoPath) - if err != nil || !info.IsDir() { + // 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 "" } - // Execute git command to get the first commit date - // Format: YYYY-MM (to match StartDate format) - cmd := exec.Command("git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m") - cmd.Dir = repoPath + // 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 "" } diff --git a/internal/handlers/cv.go.backup b/internal/handlers/cv.go.backup new file mode 100644 index 0000000..e8c0036 --- /dev/null +++ b/internal/handlers/cv.go.backup @@ -0,0 +1,532 @@ +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() + + // Prepare template data + data := map[string]interface{}{ + "CV": cv, + "UI": ui, + "Lang": lang, + "SkillsLeft": skillsLeft, + "SkillsRight": skillsRight, + "YearsOfExperience": yearsOfExperience, + "CurrentYear": currentYear, + } + + // 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, + } + + // 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 + } +} + +// 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 + projectRoot, err := filepath.Abs(".") + 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 "" +} diff --git a/internal/handlers/cv_security_test.go b/internal/handlers/cv_security_test.go new file mode 100644 index 0000000..6daa6d3 --- /dev/null +++ b/internal/handlers/cv_security_test.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "os" + "path/filepath" + "testing" +) + +// TestValidateRepoPath tests the security validation for repository paths +func TestValidateRepoPath(t *testing.T) { + // Get project root (two levels up from handlers directory) + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + // Navigate to project root + projectRoot := filepath.Join(cwd, "..", "..") + + tests := []struct { + name string + path string + shouldErr bool + errMsg string + }{ + { + name: "Valid path within project", + path: projectRoot, + shouldErr: false, + }, + { + name: "Valid subdirectory", + path: filepath.Join(projectRoot, "data"), + shouldErr: false, + }, + { + name: "Path traversal attack - parent directory", + path: "../../../etc/passwd", + shouldErr: true, + errMsg: "repository path outside project directory", + }, + { + name: "Path traversal attack - absolute path", + path: "/etc/passwd", + shouldErr: true, + errMsg: "repository path outside project directory", + }, + { + name: "Command injection attempt - pipe", + path: "data | cat /etc/passwd", + shouldErr: true, + errMsg: "path does not exist", + }, + { + name: "Command injection attempt - semicolon", + path: "data; rm -rf /", + shouldErr: true, + errMsg: "path does not exist", + }, + { + name: "Command injection attempt - backticks", + path: "data`whoami`", + shouldErr: true, + errMsg: "path does not exist", + }, + { + name: "Non-existent path", + path: filepath.Join(projectRoot, "nonexistent-directory-12345"), + shouldErr: true, + errMsg: "path does not exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRepoPath(tt.path) + + if tt.shouldErr { + if err == nil { + t.Errorf("Expected error for path %q, got nil", tt.path) + return + } + if tt.errMsg != "" && err.Error() != "" { + // Check if error message contains expected substring + if !contains(err.Error(), tt.errMsg) { + t.Errorf("Expected error containing %q, got %q", tt.errMsg, err.Error()) + } + } + } else { + if err != nil { + t.Errorf("Unexpected error for valid path %q: %v", tt.path, err) + } + } + }) + } +} + +// TestGetGitRepoFirstCommitDate_SecurityValidation tests that malicious paths are rejected +func TestGetGitRepoFirstCommitDate_SecurityValidation(t *testing.T) { + maliciousPaths := []string{ + "../../../etc/passwd", + "/etc/passwd", + "data | cat /etc/passwd", + "data; whoami", + "data`id`", + "$(whoami)", + } + + for _, path := range maliciousPaths { + t.Run("Reject_"+path, func(t *testing.T) { + // Should return empty string (safe rejection) + result := getGitRepoFirstCommitDate(path) + if result != "" { + t.Errorf("Expected empty result for malicious path %q, got %q", path, result) + } + }) + } +} + +// TestGetGitRepoFirstCommitDate_Timeout tests that git commands timeout appropriately +func TestGetGitRepoFirstCommitDate_Timeout(t *testing.T) { + // Create a temporary directory that exists but is not a git repo + tempDir := t.TempDir() + + // This should timeout/fail gracefully (not hang) + result := getGitRepoFirstCommitDate(tempDir) + + // Should return empty string for non-git repos + if result != "" { + t.Errorf("Expected empty result for non-git directory, got %q", result) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go index 8563954..79ea1ba 100644 --- a/internal/handlers/health.go +++ b/internal/handlers/health.go @@ -5,13 +5,24 @@ import ( "log" "net/http" "time" + + "github.com/juanatsap/cv-site/internal/models" ) // HealthResponse represents the health check response type HealthResponse struct { - Status string `json:"status"` - Timestamp time.Time `json:"timestamp"` - Version string `json:"version"` + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + Version string `json:"version"` + Cache *CacheInfo `json:"cache,omitempty"` +} + +// CacheInfo represents cache statistics +type CacheInfo struct { + Hits int64 `json:"hits"` + Misses int64 `json:"misses"` + Size int `json:"size"` + HitRate float64 `json:"hit_rate_percent"` } // HealthHandler handles health check requests @@ -34,6 +45,17 @@ func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) { Version: h.version, } + // Include cache stats if available + if cache := models.GetCache(); cache != nil { + stats := cache.GetStats() + response.Cache = &CacheInfo{ + Hits: stats.Hits, + Misses: stats.Misses, + Size: stats.Size, + HitRate: cache.HitRate(), + } + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(response); err != nil { diff --git a/internal/models/cv.go b/internal/models/cv.go index 3c51ad7..0a4dc93 100644 --- a/internal/models/cv.go +++ b/internal/models/cv.go @@ -4,7 +4,11 @@ import ( "encoding/json" "fmt" "html/template" + "log" "os" + "time" + + "github.com/juanatsap/cv-site/internal/cache" ) // CV represents the complete curriculum vitae structure @@ -189,14 +193,41 @@ type TechStack struct { CSS3 string `json:"css3"` } +// Global cache instance (initialized in main.go) +var cvCache *cache.CVCache + +// InitCache initializes the global cache with specified TTL +func InitCache(ttl time.Duration) { + cvCache = cache.New(ttl) + log.Printf("✓ Cache initialized (TTL: %v)", ttl) +} + +// GetCache returns the cache instance (for stats/management) +func GetCache() *cache.CVCache { + return cvCache +} + // LoadCV loads CV data from a JSON file for the specified language +// Uses cache if available, falls back to disk on cache miss func LoadCV(lang string) (*CV, error) { // Validate language if lang != "en" && lang != "es" { return nil, fmt.Errorf("unsupported language: %s", lang) } - // Determine which JSON file to load + // Try cache first if available + if cvCache != nil { + cacheKey := cache.BuildKey("cv", lang) + if cached, found := cvCache.Get(cacheKey); found { + if cv, ok := cached.(*CV); ok { + return cv, nil + } + // Invalid cache entry, invalidate it + cvCache.Invalidate(cacheKey) + } + } + + // Cache miss or no cache - load from disk filename := fmt.Sprintf("data/cv-%s.json", lang) // Read the JSON file @@ -211,17 +242,36 @@ func LoadCV(lang string) (*CV, error) { return nil, fmt.Errorf("error parsing JSON: %w", err) } + // Store in cache if available + if cvCache != nil { + cacheKey := cache.BuildKey("cv", lang) + cvCache.Set(cacheKey, &cv) + } + return &cv, nil } // LoadUI loads UI translations from a JSON file for the specified language +// Uses cache if available, falls back to disk on cache miss func LoadUI(lang string) (*UI, error) { // Validate language if lang != "en" && lang != "es" { return nil, fmt.Errorf("unsupported language: %s", lang) } - // Determine which JSON file to load + // Try cache first if available + if cvCache != nil { + cacheKey := cache.BuildKey("ui", lang) + if cached, found := cvCache.Get(cacheKey); found { + if ui, ok := cached.(*UI); ok { + return ui, nil + } + // Invalid cache entry, invalidate it + cvCache.Invalidate(cacheKey) + } + } + + // Cache miss or no cache - load from disk filename := fmt.Sprintf("data/ui-%s.json", lang) // Read the JSON file @@ -236,5 +286,11 @@ func LoadUI(lang string) (*UI, error) { return nil, fmt.Errorf("error parsing JSON: %w", err) } + // Store in cache if available + if cvCache != nil { + cacheKey := cache.BuildKey("ui", lang) + cvCache.Set(cacheKey, &ui) + } + return &ui, nil } diff --git a/internal/templates/template.go b/internal/templates/template.go index ddda09b..c20035e 100644 --- a/internal/templates/template.go +++ b/internal/templates/template.go @@ -47,9 +47,9 @@ func (m *Manager) loadTemplates() error { "eq": func(a, b string) bool { return a == b }, - "safeHTML": func(s string) template.HTML { - return template.HTML(s) - }, + // Security: safeHTML function removed to prevent XSS attacks + // Go's html/template package automatically escapes HTML by default + // If you need to render HTML, sanitize it first with a proper library } // Parse main templates diff --git a/main.go b/main.go index 98c2497..9bfe4e1 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "github.com/juanatsap/cv-site/internal/config" "github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/middleware" + "github.com/juanatsap/cv-site/internal/models" "github.com/juanatsap/cv-site/internal/templates" ) @@ -27,6 +28,29 @@ func main() { cfg := config.Load() log.Printf("✓ Configuration loaded (env: %s)", os.Getenv("GO_ENV")) + // Initialize cache (1 hour TTL, configurable via env) + cacheTTL := 1 * time.Hour + if ttlEnv := os.Getenv("CACHE_TTL_MINUTES"); ttlEnv != "" { + if minutes, err := time.ParseDuration(ttlEnv + "m"); err == nil { + cacheTTL = minutes + } + } + models.InitCache(cacheTTL) + + // Warm cache with default languages + log.Println("🔥 Warming cache...") + for _, lang := range []string{"en", "es"} { + // Warm CV cache + if _, err := models.LoadCV(lang); err != nil { + log.Printf("⚠️ Failed to warm CV cache for %s: %v", lang, err) + } + // Warm UI cache + if _, err := models.LoadUI(lang); err != nil { + log.Printf("⚠️ Failed to warm UI cache for %s: %v", lang, err) + } + } + log.Printf("✓ Cache warmed (TTL: %v)", cacheTTL) + // Initialize template manager templateMgr, err := templates.NewManager(&cfg.Template) if err != nil { diff --git a/templates/cv-content.html b/templates/cv-content.html index 5f15d55..03e3c4b 100644 --- a/templates/cv-content.html +++ b/templates/cv-content.html @@ -119,13 +119,13 @@ {{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}} - ({{.Location}}) {{if .ShortDescription}} -

{{.ShortDescription | safeHTML}}

+

{{.ShortDescription}}

{{end}} {{if .Responsibilities}} {{end}} @@ -177,13 +177,13 @@ {{.Issuer}} - {{.Date}} {{if .ShortDescription}} -

{{.ShortDescription | safeHTML}}

+

{{.ShortDescription}}

{{end}} {{if .Responsibilities}} {{end}} @@ -229,13 +229,13 @@ {{if .StartDate}}{{.StartDate}}{{if .Current}}{{if .DynamicDate}} / {{.DynamicDate}}{{else}} / {{if eq $.Lang "es"}}presente{{else}}ahora{{end}}{{end}}{{end}}{{end}} - ({{.Location}}) {{if .ShortDescription}} -

{{.ShortDescription | safeHTML}}

+

{{.ShortDescription}}

{{end}} {{if .Responsibilities}} {{end}} @@ -285,13 +285,13 @@ {{.Institution}} - {{.Date}} - ({{.Location}}) {{if .ShortDescription}} -

{{.ShortDescription | safeHTML}}

+

{{.ShortDescription}}

{{end}} {{if .Responsibilities}} {{end}} @@ -349,7 +349,7 @@
- {{if eq .Lang "es"}}Carnet de conducir tipo {{.CV.Other.DriverLicense | safeHTML}}{{else}}Driving License type {{.CV.Other.DriverLicense | safeHTML}}{{end}} + {{if eq .Lang "es"}}Carnet de conducir tipo {{.CV.Other.DriverLicense}}{{else}}Driving License type {{.CV.Other.DriverLicense}}{{end}}
diff --git a/templates/index.html b/templates/index.html index 13ac14b..bf7d47a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,7 +11,12 @@ - + + + + + +