diff --git a/config/.env.example b/config/.env.example
new file mode 100644
index 0000000..dbf2087
--- /dev/null
+++ b/config/.env.example
@@ -0,0 +1,62 @@
+# Environment Configuration Example
+# Copy this file to .env and customize as needed
+
+# Server Configuration
+PORT=1999
+HOST=localhost
+GO_ENV=development
+
+# Template Configuration
+TEMPLATE_DIR=templates
+PARTIALS_DIR=templates/partials
+TEMPLATE_HOT_RELOAD=true
+
+# Data Configuration
+DATA_DIR=data
+
+# Server Timeouts (seconds)
+READ_TIMEOUT=15
+WRITE_TIMEOUT=15
+
+# Security Configuration
+# Allowed origins for API access (comma-separated domains)
+# Prevents external sites from accessing your API/PDF endpoint
+#
+# DEFAULT: If empty, defaults to juan.andres.morenorub.io (the CV site domain)
+# Plus localhost and 127.0.0.1 are always allowed in development
+#
+# For custom domains in production: ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com
+# Multiple domains: ALLOWED_ORIGINS=domain1.com,domain2.com,www.domain1.com
+ALLOWED_ORIGINS=
+
+# Rate Limiter Configuration
+# CRITICAL: Prevents IP spoofing attacks that bypass rate limiting
+#
+# BEHIND_PROXY: Set to true ONLY if behind a trusted reverse proxy (nginx, caddy, cloudflare)
+# - Development (default): false - Uses RemoteAddr only, immune to header spoofing
+# - Production behind proxy: true - Trusts X-Forwarded-For from proxy
+#
+# TRUSTED_PROXY_IP: Optional - IP address of your reverse proxy
+# - If set, only X-Forwarded-For headers from this IP are trusted
+# - Example: 127.0.0.1 (for local nginx), 10.0.0.1 (for load balancer)
+# - Leave empty to trust X-Forwarded-For from any source (less secure)
+#
+# Security Impact:
+# - BEHIND_PROXY=false (dev): Ignores all X-Forwarded-For headers, uses actual connection IP
+# - BEHIND_PROXY=true (prod): Trusts proxy, extracts client IP from X-Forwarded-For
+# - Logs all suspicious spoofing attempts for security monitoring
+#
+BEHIND_PROXY=false
+TRUSTED_PROXY_IP=
+
+# Production Settings
+# Uncomment for production:
+# GO_ENV=production
+# TEMPLATE_HOT_RELOAD=false
+# READ_TIMEOUT=30
+# WRITE_TIMEOUT=30
+# ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com
+#
+# Production behind reverse proxy:
+# BEHIND_PROXY=true
+# TRUSTED_PROXY_IP=127.0.0.1
diff --git a/internal/handlers/cv.go.backup b/internal/handlers/cv.go.backup
deleted file mode 100644
index e8c0036..0000000
--- a/internal/handlers/cv.go.backup
+++ /dev/null
@@ -1,532 +0,0 @@
-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
-
-
-
-
-
-
-`, lang, message, lang, message, body, redirectMsg)
-
- if _, err := w.Write([]byte(html)); err != nil {
- log.Printf("Error writing response: %v", err)
- }
-}
-
-// splitSkills splits skill categories between left (page 1) and right (page 2) sidebars
-// Each category explicitly specifies which sidebar it belongs to via the "sidebar" field
-func splitSkills(skills []models.SkillCategory) (left, right []models.SkillCategory) {
- if len(skills) == 0 {
- return nil, nil
- }
-
- // Filter by sidebar field
- for _, skill := range skills {
- if skill.Sidebar == "right" {
- right = append(right, skill)
- } else {
- // Default to left if not specified or if set to "left"
- left = append(left, skill)
- }
- }
-
- return left, right
-}
-
-// calculateYearsOfExperience calculates years of experience since April 1, 2005
-// This matches the original React implementation that calculated from 01/04/2005
-func calculateYearsOfExperience() int {
- // First day at work: April 1, 2005
- firstDay := time.Date(2005, time.April, 1, 9, 0, 0, 0, time.UTC)
-
- // Current date
- now := time.Now()
-
- // Calculate the difference in years
- years := now.Year() - firstDay.Year()
-
- // Adjust if we haven't reached the anniversary this year yet
- if now.Month() < firstDay.Month() ||
- (now.Month() == firstDay.Month() && now.Day() < firstDay.Day()) {
- years--
- }
-
- return years
-}
-
-// calculateDuration calculates the duration between two dates in years and months
-// Date format expected: "YYYY-MM" (e.g., "2021-01")
-// Returns a formatted string like "3 years 6 months" or "6 months"
-func calculateDuration(startDate, endDate string, current bool, lang string) string {
- // Parse start date
- start, err := time.Parse("2006-01", startDate)
- if err != nil {
- return ""
- }
-
- // Determine end date
- var end time.Time
- if current {
- end = time.Now()
- } else {
- end, err = time.Parse("2006-01", endDate)
- if err != nil {
- return ""
- }
- }
-
- // Calculate total months
- totalMonths := (end.Year()-start.Year())*12 + int(end.Month()-start.Month())
-
- // If end date is before start date, return empty
- if totalMonths < 0 {
- return ""
- }
-
- years := totalMonths / 12
- months := totalMonths % 12
-
- // Format the duration string based on language
- var result string
- if lang == "es" {
- if years > 0 && months > 0 {
- yearStr := "años"
- if years == 1 {
- yearStr = "año"
- }
- monthStr := "meses"
- if months == 1 {
- monthStr = "mes"
- }
- result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr)
- } else if years > 0 {
- yearStr := "años"
- if years == 1 {
- yearStr = "año"
- }
- result = fmt.Sprintf("(%d %s)", years, yearStr)
- } else {
- monthStr := "meses"
- if months == 1 {
- monthStr = "mes"
- }
- result = fmt.Sprintf("(%d %s)", months, monthStr)
- }
- } else {
- if years > 0 && months > 0 {
- yearStr := "years"
- if years == 1 {
- yearStr = "year"
- }
- monthStr := "months"
- if months == 1 {
- monthStr = "month"
- }
- result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr)
- } else if years > 0 {
- yearStr := "years"
- if years == 1 {
- yearStr = "year"
- }
- result = fmt.Sprintf("(%d %s)", years, yearStr)
- } else {
- monthStr := "months"
- if months == 1 {
- monthStr = "month"
- }
- result = fmt.Sprintf("(%d %s)", months, monthStr)
- }
- }
-
- return result
-}
-
-// processProjectDates calculates dynamic dates for projects
-// If a project has a gitRepoUrl, it fetches the first commit date
-// For current projects, it sets the current system date
-func processProjectDates(project *models.Project, lang string) {
- now := time.Now()
-
- // Set dynamic current date for ongoing projects
- if project.Current {
- if lang == "es" {
- project.DynamicDate = "Presente"
- } else {
- project.DynamicDate = "Present"
- }
- }
-
- // If project has a git repository URL, fetch the first commit date
- if project.GitRepoUrl != "" {
- commitDate := getGitRepoFirstCommitDate(project.GitRepoUrl)
- if commitDate != "" {
- project.ComputedStartDate = commitDate
- }
- }
-
- // If no computed date and no static date, use current date for current projects
- if project.ComputedStartDate == "" && project.StartDate == "" && project.Current {
- project.ComputedStartDate = now.Format("2006-01")
- }
-
- // If we have a computed date but no static date, use the computed one
- if project.ComputedStartDate != "" && project.StartDate == "" {
- project.StartDate = project.ComputedStartDate
- }
-}
-
-// validateRepoPath validates that a repository path is safe to use
-// Security: Prevents path traversal and command injection attacks
-// Only allows paths within the project directory
-func validateRepoPath(path string) error {
- // Resolve to absolute path to prevent path traversal
- absPath, err := filepath.Abs(path)
- if err != nil {
- return fmt.Errorf("invalid path: %w", err)
- }
-
- // Get project root directory
- projectRoot, err := filepath.Abs(".")
- if err != nil {
- return fmt.Errorf("cannot determine project root: %w", err)
- }
-
- // Security check: Only allow paths within project directory
- // This prevents malicious paths like "../../../etc/passwd"
- if !strings.HasPrefix(absPath, projectRoot) {
- return fmt.Errorf("repository path outside project directory: %s", path)
- }
-
- // Verify path exists and is a directory
- info, err := os.Stat(absPath)
- if err != nil {
- return fmt.Errorf("path does not exist: %w", err)
- }
- if !info.IsDir() {
- return fmt.Errorf("path is not a directory: %s", path)
- }
-
- return nil
-}
-
-// getGitRepoFirstCommitDate fetches the first commit date from a git repository
-// Supports local git repository paths
-// Security: Validates path and uses timeout to prevent hanging
-func getGitRepoFirstCommitDate(repoPath string) string {
- // Security: Validate repository path before executing git command
- if err := validateRepoPath(repoPath); err != nil {
- log.Printf("Security: Rejected git operation for invalid path %s: %v", repoPath, err)
- return ""
- }
-
- // Security: Add timeout context to prevent hanging
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- // Execute git command with timeout protection
- // Using CommandContext for automatic cancellation on timeout
- cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m")
-
- output, err := cmd.Output()
- if err != nil {
- // Log error but don't expose details to prevent information disclosure
- log.Printf("Git command failed for path %s: %v", repoPath, err)
- return ""
- }
-
- // Parse the output to get the first commit date
- lines := strings.Split(strings.TrimSpace(string(output)), "\n")
- if len(lines) == 0 {
- return ""
- }
-
- // Extract YYYY-MM from the first commit timestamp
- // Format of output: "2024-06-15 10:30:45 +0200"
- firstLine := lines[0]
- parts := strings.Fields(firstLine)
- if len(parts) > 0 {
- datePart := parts[0] // "2024-06-15"
- dateParts := strings.Split(datePart, "-")
- if len(dateParts) >= 2 {
- return dateParts[0] + "-" + dateParts[1] // "2024-06"
- }
- }
-
- return ""
-}
diff --git a/static/js/main.js b/static/js/main.js
index e1173f9..634d214 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -357,15 +357,19 @@
// Use CSS zoom property - it properly affects layout and extends beyond viewport
zoomWrapper.style.zoom = zoomLevel;
- // When zoom > 100%, set a min-width to allow horizontal expansion beyond viewport
- // This allows the content to grow larger than the viewport width
+ // When zoom > 100%, allow the wrapper to expand beyond viewport width
+ // Set width to accommodate the expanded content without bounds
if (zoomLevel > 1) {
- const viewportWidth = window.innerWidth;
- // Set min-width to ensure content can expand beyond viewport
- zoomWrapper.style.minWidth = `${viewportWidth}px`;
+ // Set width to auto to allow natural expansion
+ zoomWrapper.style.width = 'auto';
+ zoomWrapper.style.minWidth = '100%';
+ // Remove max-width constraint to allow horizontal expansion
+ zoomWrapper.style.maxWidth = 'none';
} else {
// Reset to default when zoom <= 100%
+ zoomWrapper.style.width = '';
zoomWrapper.style.minWidth = '';
+ zoomWrapper.style.maxWidth = '';
}
// Reset zoom on fixed buttons so they stay same size
@@ -383,6 +387,9 @@
if (saveToStorage) {
localStorage.setItem('cv-zoom', zoomValue.toString());
}
+
+ // Update zoom control position for horizontal scroll
+ updateZoomControlPosition();
});
}
@@ -429,6 +436,32 @@
applyZoom(newZoom, true);
}
+ /**
+ * Update zoom control position based on horizontal scroll
+ * This keeps the zoom control centered relative to the visible viewport
+ */
+ function updateZoomControlPosition() {
+ const zoomControl = document.getElementById('zoom-control');
+ if (!zoomControl || isMobileView()) return;
+
+ // Only adjust if zoom control is in default centered position
+ // (not dragged to a custom position)
+ const savedPosition = localStorage.getItem('cv-zoom-position');
+ if (savedPosition) return; // Don't adjust if user has dragged it
+
+ // Get current horizontal scroll position
+ const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
+
+ // Update left position to account for horizontal scroll
+ if (scrollLeft > 0) {
+ // Adjust position to stay centered in viewport during horizontal scroll
+ zoomControl.style.left = `calc(50% + ${scrollLeft}px)`;
+ } else {
+ // Reset to center when scroll is at start
+ zoomControl.style.left = '50%';
+ }
+ }
+
/**
* Make zoom control draggable and persist position
*/
@@ -708,6 +741,9 @@
const currentScroll = window.pageYOffset || document.documentElement.scrollTop;
const isMenuOpen = navMenu.classList.contains('menu-open');
+ // Update zoom control position on horizontal scroll
+ updateZoomControlPosition();
+
// Check if at bottom of page (within 50px threshold)
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;