feat: exclude PSD files from version control
This commit is contained in:
@@ -30,3 +30,5 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.log
|
||||
cv-app
|
||||
static/psd
|
||||
static/psd/yo DNI.psd
|
||||
|
||||
Executable
+121
@@ -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
Binary file not shown.
Vendored
+189
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user