first commit
This commit is contained in:
@@ -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
|
||||||
@@ -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 <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 ""
|
|
||||||
}
|
|
||||||
+41
-5
@@ -357,15 +357,19 @@
|
|||||||
// Use CSS zoom property - it properly affects layout and extends beyond viewport
|
// Use CSS zoom property - it properly affects layout and extends beyond viewport
|
||||||
zoomWrapper.style.zoom = zoomLevel;
|
zoomWrapper.style.zoom = zoomLevel;
|
||||||
|
|
||||||
// When zoom > 100%, set a min-width to allow horizontal expansion beyond viewport
|
// When zoom > 100%, allow the wrapper to expand beyond viewport width
|
||||||
// This allows the content to grow larger than the viewport width
|
// Set width to accommodate the expanded content without bounds
|
||||||
if (zoomLevel > 1) {
|
if (zoomLevel > 1) {
|
||||||
const viewportWidth = window.innerWidth;
|
// Set width to auto to allow natural expansion
|
||||||
// Set min-width to ensure content can expand beyond viewport
|
zoomWrapper.style.width = 'auto';
|
||||||
zoomWrapper.style.minWidth = `${viewportWidth}px`;
|
zoomWrapper.style.minWidth = '100%';
|
||||||
|
// Remove max-width constraint to allow horizontal expansion
|
||||||
|
zoomWrapper.style.maxWidth = 'none';
|
||||||
} else {
|
} else {
|
||||||
// Reset to default when zoom <= 100%
|
// Reset to default when zoom <= 100%
|
||||||
|
zoomWrapper.style.width = '';
|
||||||
zoomWrapper.style.minWidth = '';
|
zoomWrapper.style.minWidth = '';
|
||||||
|
zoomWrapper.style.maxWidth = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset zoom on fixed buttons so they stay same size
|
// Reset zoom on fixed buttons so they stay same size
|
||||||
@@ -383,6 +387,9 @@
|
|||||||
if (saveToStorage) {
|
if (saveToStorage) {
|
||||||
localStorage.setItem('cv-zoom', zoomValue.toString());
|
localStorage.setItem('cv-zoom', zoomValue.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update zoom control position for horizontal scroll
|
||||||
|
updateZoomControlPosition();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +436,32 @@
|
|||||||
applyZoom(newZoom, true);
|
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
|
* Make zoom control draggable and persist position
|
||||||
*/
|
*/
|
||||||
@@ -708,6 +741,9 @@
|
|||||||
const currentScroll = window.pageYOffset || document.documentElement.scrollTop;
|
const currentScroll = window.pageYOffset || document.documentElement.scrollTop;
|
||||||
const isMenuOpen = navMenu.classList.contains('menu-open');
|
const isMenuOpen = navMenu.classList.contains('menu-open');
|
||||||
|
|
||||||
|
// Update zoom control position on horizontal scroll
|
||||||
|
updateZoomControlPosition();
|
||||||
|
|
||||||
// Check if at bottom of page (within 50px threshold)
|
// Check if at bottom of page (within 50px threshold)
|
||||||
const scrollHeight = document.documentElement.scrollHeight;
|
const scrollHeight = document.documentElement.scrollHeight;
|
||||||
const clientHeight = document.documentElement.clientHeight;
|
const clientHeight = document.documentElement.clientHeight;
|
||||||
|
|||||||
Reference in New Issue
Block a user