feat: exclude PSD files from version control

This commit is contained in:
juanatsap
2025-11-11 13:53:14 +00:00
parent 97aa8971c1
commit 1f5aeb1c4c
13 changed files with 1200 additions and 25 deletions
+2
View File
@@ -30,3 +30,5 @@ Thumbs.db
*.tmp
*.log
cv-app
static/psd
static/psd/yo DNI.psd
+121
View File
@@ -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}"
Executable
BIN
View File
Binary file not shown.
+189
View File
@@ -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)
}
+85 -7
View File
@@ -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 ""
}
+532
View File
@@ -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 <strong>Print Friendly</strong> 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 <strong>Imprimir Amigable</strong> 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(`<!DOCTYPE html>
<html lang="%s">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
}
.container {
background: white;
padding: 3rem;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 500px;
text-align: center;
}
h1 {
color: #333;
margin: 0 0 1rem 0;
font-size: 1.8rem;
}
p {
color: #666;
line-height: 1.6;
margin: 1rem 0;
}
.redirect-info {
margin-top: 2rem;
padding: 1rem;
background: #f0f4ff;
border-radius: 8px;
font-size: 0.9rem;
color: #555;
}
.icon {
font-size: 3rem;
margin-bottom: 1rem;
}
</style>
<script>
setTimeout(function() {
window.location.href = '/?lang=%s';
}, 5000);
</script>
</head>
<body>
<div class="container">
<div class="icon">🚧</div>
<h1>%s</h1>
<p>%s</p>
<div class="redirect-info">
%s
</div>
</div>
</body>
</html>`, 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 ""
}
+146
View File
@@ -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
}
+25 -3
View File
@@ -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 {
+58 -2
View File
@@ -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
}
+3 -3
View File
@@ -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
+24
View File
@@ -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 {
+9 -9
View File
@@ -119,13 +119,13 @@
<small>{{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}} - ({{.Location}})</small>
{{if .ShortDescription}}
<p class="experience-desc short-desc">{{.ShortDescription | safeHTML}}</p>
<p class="experience-desc short-desc">{{.ShortDescription}}</p>
{{end}}
{{if .Responsibilities}}
<ul class="responsibilities long-only">
{{range .Responsibilities}}
<li>{{. | safeHTML}}</li>
<li>{{.}}</li>
{{end}}
</ul>
{{end}}
@@ -177,13 +177,13 @@
<small>{{.Issuer}} - {{.Date}}</small>
{{if .ShortDescription}}
<p class="award-desc short-desc">{{.ShortDescription | safeHTML}}</p>
<p class="award-desc short-desc">{{.ShortDescription}}</p>
{{end}}
{{if .Responsibilities}}
<ul class="responsibilities long-only">
{{range .Responsibilities}}
<li>{{. | safeHTML}}</li>
<li>{{.}}</li>
{{end}}
</ul>
{{end}}
@@ -229,13 +229,13 @@
<small>{{if .StartDate}}{{.StartDate}}{{if .Current}}{{if .DynamicDate}} / {{.DynamicDate}}{{else}} / {{if eq $.Lang "es"}}presente{{else}}ahora{{end}}{{end}}{{end}}{{end}} - ({{.Location}})</small>
{{if .ShortDescription}}
<p class="project-desc short-desc">{{.ShortDescription | safeHTML}}</p>
<p class="project-desc short-desc">{{.ShortDescription}}</p>
{{end}}
{{if .Responsibilities}}
<ul class="responsibilities long-only">
{{range .Responsibilities}}
<li>{{. | safeHTML}}</li>
<li>{{.}}</li>
{{end}}
</ul>
{{end}}
@@ -285,13 +285,13 @@
<small>{{.Institution}} - {{.Date}} - ({{.Location}})</small>
{{if .ShortDescription}}
<p class="course-desc short-desc">{{.ShortDescription | safeHTML}}</p>
<p class="course-desc short-desc">{{.ShortDescription}}</p>
{{end}}
{{if .Responsibilities}}
<ul class="responsibilities long-only">
{{range .Responsibilities}}
<li>{{. | safeHTML}}</li>
<li>{{.}}</li>
{{end}}
</ul>
{{end}}
@@ -349,7 +349,7 @@
</h3>
</summary>
<div class="other-content">
{{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}}
</div>
</details>
</section>
+6 -1
View File
@@ -11,7 +11,12 @@
<meta name="keywords" content="{{if eq .Lang "es"}}CV, Curriculum Vitae, {{.CV.Personal.Name}}, Desarrollador FullStack, SAP CDC, React, Node.js, Go, HTMX, IA, Desarrollo Web, Consultor Técnico{{else}}CV, Resume, {{.CV.Personal.Name}}, FullStack Developer, SAP CDC, React, Node.js, Go, HTMX, AI, Web Development, Technical Consultant{{end}}">
<meta name="author" content="{{.CV.Personal.Name}}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{.CV.Personal.Website}}">
<link rel="canonical" href="{{.CanonicalURL}}">
<!-- Hreflang tags for international SEO -->
<link rel="alternate" hreflang="en" href="{{.AlternateEN}}">
<link rel="alternate" hreflang="es" href="{{.AlternateES}}">
<link rel="alternate" hreflang="x-default" href="https://juan.andres.morenorub.io/?lang=en">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="profile">