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
+{{.ShortDescription | safeHTML}}
+{{.ShortDescription}}
{{end}} {{if .Responsibilities}}{{.ShortDescription | safeHTML}}
+{{.ShortDescription}}
{{end}} {{if .Responsibilities}}{{.ShortDescription | safeHTML}}
+{{.ShortDescription}}
{{end}} {{if .Responsibilities}}{{.ShortDescription | safeHTML}}
+{{.ShortDescription}}
{{end}} {{if .Responsibilities}}