From be04b2dbc21bfc1024bc998530ae7ea72251ab2d Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 12 Nov 2025 18:14:48 +0000 Subject: [PATCH] first commit --- config/.env.example | 62 ++++ internal/handlers/cv.go.backup | 532 --------------------------------- static/js/main.js | 46 ++- 3 files changed, 103 insertions(+), 537 deletions(-) create mode 100644 config/.env.example delete mode 100644 internal/handlers/cv.go.backup 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 - - - - -
-
🚧
-

%s

-

%s

-
- %s -
-
- -`, lang, message, lang, message, body, redirectMsg) - - if _, err := w.Write([]byte(html)); err != nil { - log.Printf("Error writing response: %v", err) - } -} - -// splitSkills splits skill categories between left (page 1) and right (page 2) sidebars -// Each category explicitly specifies which sidebar it belongs to via the "sidebar" field -func splitSkills(skills []models.SkillCategory) (left, right []models.SkillCategory) { - if len(skills) == 0 { - return nil, nil - } - - // Filter by sidebar field - for _, skill := range skills { - if skill.Sidebar == "right" { - right = append(right, skill) - } else { - // Default to left if not specified or if set to "left" - left = append(left, skill) - } - } - - return left, right -} - -// calculateYearsOfExperience calculates years of experience since April 1, 2005 -// This matches the original React implementation that calculated from 01/04/2005 -func calculateYearsOfExperience() int { - // First day at work: April 1, 2005 - firstDay := time.Date(2005, time.April, 1, 9, 0, 0, 0, time.UTC) - - // Current date - now := time.Now() - - // Calculate the difference in years - years := now.Year() - firstDay.Year() - - // Adjust if we haven't reached the anniversary this year yet - if now.Month() < firstDay.Month() || - (now.Month() == firstDay.Month() && now.Day() < firstDay.Day()) { - years-- - } - - return years -} - -// calculateDuration calculates the duration between two dates in years and months -// Date format expected: "YYYY-MM" (e.g., "2021-01") -// Returns a formatted string like "3 years 6 months" or "6 months" -func calculateDuration(startDate, endDate string, current bool, lang string) string { - // Parse start date - start, err := time.Parse("2006-01", startDate) - if err != nil { - return "" - } - - // Determine end date - var end time.Time - if current { - end = time.Now() - } else { - end, err = time.Parse("2006-01", endDate) - if err != nil { - return "" - } - } - - // Calculate total months - totalMonths := (end.Year()-start.Year())*12 + int(end.Month()-start.Month()) - - // If end date is before start date, return empty - if totalMonths < 0 { - return "" - } - - years := totalMonths / 12 - months := totalMonths % 12 - - // Format the duration string based on language - var result string - if lang == "es" { - if years > 0 && months > 0 { - yearStr := "años" - if years == 1 { - yearStr = "año" - } - monthStr := "meses" - if months == 1 { - monthStr = "mes" - } - result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) - } else if years > 0 { - yearStr := "años" - if years == 1 { - yearStr = "año" - } - result = fmt.Sprintf("(%d %s)", years, yearStr) - } else { - monthStr := "meses" - if months == 1 { - monthStr = "mes" - } - result = fmt.Sprintf("(%d %s)", months, monthStr) - } - } else { - if years > 0 && months > 0 { - yearStr := "years" - if years == 1 { - yearStr = "year" - } - monthStr := "months" - if months == 1 { - monthStr = "month" - } - result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) - } else if years > 0 { - yearStr := "years" - if years == 1 { - yearStr = "year" - } - result = fmt.Sprintf("(%d %s)", years, yearStr) - } else { - monthStr := "months" - if months == 1 { - monthStr = "month" - } - result = fmt.Sprintf("(%d %s)", months, monthStr) - } - } - - return result -} - -// processProjectDates calculates dynamic dates for projects -// If a project has a gitRepoUrl, it fetches the first commit date -// For current projects, it sets the current system date -func processProjectDates(project *models.Project, lang string) { - now := time.Now() - - // Set dynamic current date for ongoing projects - if project.Current { - if lang == "es" { - project.DynamicDate = "Presente" - } else { - project.DynamicDate = "Present" - } - } - - // If project has a git repository URL, fetch the first commit date - if project.GitRepoUrl != "" { - commitDate := getGitRepoFirstCommitDate(project.GitRepoUrl) - if commitDate != "" { - project.ComputedStartDate = commitDate - } - } - - // If no computed date and no static date, use current date for current projects - if project.ComputedStartDate == "" && project.StartDate == "" && project.Current { - project.ComputedStartDate = now.Format("2006-01") - } - - // If we have a computed date but no static date, use the computed one - if project.ComputedStartDate != "" && project.StartDate == "" { - project.StartDate = project.ComputedStartDate - } -} - -// validateRepoPath validates that a repository path is safe to use -// Security: Prevents path traversal and command injection attacks -// Only allows paths within the project directory -func validateRepoPath(path string) error { - // Resolve to absolute path to prevent path traversal - absPath, err := filepath.Abs(path) - if err != nil { - return fmt.Errorf("invalid path: %w", err) - } - - // Get project root directory - projectRoot, err := filepath.Abs(".") - if err != nil { - return fmt.Errorf("cannot determine project root: %w", err) - } - - // Security check: Only allow paths within project directory - // This prevents malicious paths like "../../../etc/passwd" - if !strings.HasPrefix(absPath, projectRoot) { - return fmt.Errorf("repository path outside project directory: %s", path) - } - - // Verify path exists and is a directory - info, err := os.Stat(absPath) - if err != nil { - return fmt.Errorf("path does not exist: %w", err) - } - if !info.IsDir() { - return fmt.Errorf("path is not a directory: %s", path) - } - - return nil -} - -// getGitRepoFirstCommitDate fetches the first commit date from a git repository -// Supports local git repository paths -// Security: Validates path and uses timeout to prevent hanging -func getGitRepoFirstCommitDate(repoPath string) string { - // Security: Validate repository path before executing git command - if err := validateRepoPath(repoPath); err != nil { - log.Printf("Security: Rejected git operation for invalid path %s: %v", repoPath, err) - return "" - } - - // Security: Add timeout context to prevent hanging - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Execute git command with timeout protection - // Using CommandContext for automatic cancellation on timeout - cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m") - - output, err := cmd.Output() - if err != nil { - // Log error but don't expose details to prevent information disclosure - log.Printf("Git command failed for path %s: %v", repoPath, err) - return "" - } - - // Parse the output to get the first commit date - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(lines) == 0 { - return "" - } - - // Extract YYYY-MM from the first commit timestamp - // Format of output: "2024-06-15 10:30:45 +0200" - firstLine := lines[0] - parts := strings.Fields(firstLine) - if len(parts) > 0 { - datePart := parts[0] // "2024-06-15" - dateParts := strings.Split(datePart, "-") - if len(dateParts) >= 2 { - return dateParts[0] + "-" + dateParts[1] // "2024-06" - } - } - - return "" -} diff --git a/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;