fix: Mobile hamburger menu and iPad sidebar visibility

Mobile fixes:
- Add click toggle handler for hamburger menu (was hover-only)
- Menu now opens/closes on tap and closes when clicking outside
- Keep hover support for desktop

iPad fixes:
- Sidebar content now visible on touch devices (901-1280px)
- Added (hover: hover) media query to prevent hide-on-hover on tablets

Security improvements:
- Replace exec.CommandContext with go-git library for git operations
- Add path traversal and command injection prevention
- Fix race condition in template hot reload
- Add environment-based cookie Secure flag

Code quality:
- Add constants.go for magic numbers
- Remove unused code (ParsePreferenceToggleRequest, DomainError)
- Add FOUC prevention with inline critical CSS
- Add Makefile dev/run/clean targets
- Fix README git clone URL
- Add doc/DECISIONS.md for architectural decisions

Tests:
- Add hamburger menu click toggle tests
- Add iPad sidebar visibility tests
- Update security tests for go-git implementation
- Add cookie Secure flag tests
This commit is contained in:
juanatsap
2025-11-30 09:29:35 +00:00
parent 60c1b5ac2b
commit eb92f64e93
18 changed files with 874 additions and 183 deletions
+123
View File
@@ -0,0 +1,123 @@
package handlers
import "time"
// ==============================================================================
// HTTP CONTENT TYPES
// ==============================================================================
const (
// ContentTypePDF is the MIME type for PDF documents
ContentTypePDF = "application/pdf"
// ContentTypeHTML is the MIME type for HTML documents
ContentTypeHTML = "text/html; charset=utf-8"
// ContentTypeJSON is the MIME type for JSON documents
ContentTypeJSON = "application/json"
// ContentTypePlainText is the MIME type for plain text
ContentTypePlainText = "text/plain; charset=utf-8"
)
// ==============================================================================
// RATE LIMITING
// ==============================================================================
const (
// PDFRateLimitRequests is the maximum number of PDF requests per window
PDFRateLimitRequests = 3
// PDFRateLimitWindow is the time window for PDF rate limiting
PDFRateLimitWindow = 1 * time.Minute
// GeneralRateLimitRequests is the default rate limit for general requests
GeneralRateLimitRequests = 100
// GeneralRateLimitWindow is the time window for general rate limiting
GeneralRateLimitWindow = 1 * time.Minute
)
// ==============================================================================
// PDF GENERATION
// ==============================================================================
const (
// A4WidthInches is the width of A4 paper in inches
A4WidthInches = 8.27
// A4HeightInches is the height of A4 paper in inches
A4HeightInches = 11.69
// PDFGenerationTimeout is the maximum time allowed for PDF generation
PDFGenerationTimeout = 30 * time.Second
)
// ==============================================================================
// COOKIE SETTINGS
// ==============================================================================
const (
// CookieMaxAge is the default cookie expiration (1 year in seconds)
CookieMaxAge = 365 * 24 * 60 * 60
// CookiePath is the default cookie path
CookiePath = "/"
)
// ==============================================================================
// LANGUAGE CODES
// ==============================================================================
const (
// LangEnglish is the English language code
LangEnglish = "en"
// LangSpanish is the Spanish language code
LangSpanish = "es"
// DefaultLanguage is the default language for the application
DefaultLanguage = LangEnglish
)
// ==============================================================================
// CV PREFERENCES
// ==============================================================================
const (
// CVLengthShort represents the short CV format
CVLengthShort = "short"
// CVLengthLong represents the long CV format
CVLengthLong = "long"
// CVIconsShow indicates icons should be visible
CVIconsShow = "show"
// CVIconsHide indicates icons should be hidden
CVIconsHide = "hide"
// CVThemeDefault is the default CV theme
CVThemeDefault = "default"
// CVThemeClean is the clean CV theme
CVThemeClean = "clean"
)
// ==============================================================================
// HTTP HEADERS
// ==============================================================================
const (
// HeaderContentType is the Content-Type header key
HeaderContentType = "Content-Type"
// HeaderContentDisposition is the Content-Disposition header key
HeaderContentDisposition = "Content-Disposition"
// HeaderCacheControl is the Cache-Control header key
HeaderCacheControl = "Cache-Control"
// HeaderHXRequest is the HTMX request header
HeaderHXRequest = "HX-Request"
)
+38 -48
View File
@@ -1,15 +1,16 @@
package handlers
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
cvmodel "github.com/juanatsap/cv-site/internal/models/cv"
uimodel "github.com/juanatsap/cv-site/internal/models/ui"
)
@@ -151,7 +152,7 @@ func calculateDuration(startDate, endDate string, current bool, lang string) str
}
// processProjectDates calculates dynamic dates for projects
// If a project has a gitRepoUrl, it fetches the first commit date
// If a project has a gitRepoUrl, it fetches the first commit date using go-git
// For current projects, it sets the current system date
func processProjectDates(project *cvmodel.Project, lang string) {
now := time.Now()
@@ -165,7 +166,7 @@ func processProjectDates(project *cvmodel.Project, lang string) {
}
}
// If project has a git repository URL, fetch the first commit date
// If project has a git repository path, fetch the first commit date
if project.GitRepoUrl != "" {
commitDate := getGitRepoFirstCommitDate(project.GitRepoUrl)
if commitDate != "" {
@@ -185,32 +186,25 @@ func processProjectDates(project *cvmodel.Project, lang string) {
}
// ==============================================================================
// GIT HELPERS
// GIT HELPERS (using go-git - pure Go implementation, no shell commands)
// ==============================================================================
// findProjectRoot finds the project root directory
// It looks for .git directory walking up the directory tree
// findProjectRoot finds the project root directory by looking for .git directory
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
@@ -218,29 +212,23 @@ func findProjectRoot() (string, error) {
}
// 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
// Security: Prevents path traversal attacks by ensuring path is within 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"
// Security: Only allow paths within project directory
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)
@@ -253,49 +241,51 @@ func validateRepoPath(path string) error {
}
// getGitRepoFirstCommitDate fetches the first commit date from a git repository
// Supports local git repository paths
// Security: Validates path and uses timeout to prevent hanging
// Uses go-git (pure Go) - no shell command execution, eliminating injection risks
func getGitRepoFirstCommitDate(repoPath string) string {
// Security: Validate repository path before executing git command
// Security: Validate repository path
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()
// Open the repository using go-git
repo, err := git.PlainOpen(repoPath)
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)
log.Printf("Failed to open git repository at %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 {
// Get the commit history
commitIter, err := repo.Log(&git.LogOptions{
Order: git.LogOrderCommitterTime,
})
if err != nil {
log.Printf("Failed to get commit log for %s: %v", repoPath, err)
return ""
}
defer commitIter.Close()
// 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"
// Find the oldest commit by iterating through all commits
var oldestCommit *object.Commit
err = commitIter.ForEach(func(c *object.Commit) error {
if oldestCommit == nil || c.Committer.When.Before(oldestCommit.Committer.When) {
oldestCommit = c
}
return nil
})
if err != nil {
log.Printf("Error iterating commits for %s: %v", repoPath, err)
return ""
}
return ""
if oldestCommit == nil {
log.Printf("No commits found in repository %s", repoPath)
return ""
}
// Return date in YYYY-MM format
return oldestCommit.Committer.When.Format("2006-01")
}
// ==============================================================================
+63 -3
View File
@@ -6,6 +6,14 @@ import (
"testing"
)
// ==============================================================================
// SECURITY TESTS for go-git Implementation
// ==============================================================================
// These tests verify that the path validation and git operations are secure.
// The implementation uses go-git (pure Go) instead of exec.CommandContext
// to eliminate shell command injection risks.
// ==============================================================================
// TestValidateRepoPath tests the security validation for repository paths
func TestValidateRepoPath(t *testing.T) {
// Get project root (two levels up from handlers directory)
@@ -116,12 +124,12 @@ func TestGetGitRepoFirstCommitDate_SecurityValidation(t *testing.T) {
}
}
// TestGetGitRepoFirstCommitDate_Timeout tests that git commands timeout appropriately
func TestGetGitRepoFirstCommitDate_Timeout(t *testing.T) {
// TestGetGitRepoFirstCommitDate_NonGitRepo tests that non-git directories return empty
func TestGetGitRepoFirstCommitDate_NonGitRepo(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)
// This should fail gracefully (not panic)
result := getGitRepoFirstCommitDate(tempDir)
// Should return empty string for non-git repos
@@ -130,6 +138,58 @@ func TestGetGitRepoFirstCommitDate_Timeout(t *testing.T) {
}
}
// TestGetGitRepoFirstCommitDate_ValidRepo tests the happy path with the current repo
func TestGetGitRepoFirstCommitDate_ValidRepo(t *testing.T) {
// Get project root (should be a valid git repo)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
projectRoot := filepath.Join(cwd, "..", "..")
// This should return a valid date
result := getGitRepoFirstCommitDate(projectRoot)
// Should return a date in YYYY-MM format
if result == "" {
t.Log("Warning: No commit date returned (repo might not have commits)")
return
}
// Validate format: YYYY-MM
if len(result) != 7 || result[4] != '-' {
t.Errorf("Expected date in YYYY-MM format, got %q", result)
}
// Year should be between 2020 and 2030 (reasonable range)
year := result[:4]
if year < "2020" || year > "2030" {
t.Errorf("Year %s seems unreasonable for project start date", year)
}
t.Logf("First commit date: %s", result)
}
// TestFindProjectRoot tests the project root detection
func TestFindProjectRoot(t *testing.T) {
root, err := findProjectRoot()
if err != nil {
t.Fatalf("Failed to find project root: %v", err)
}
// Verify .git directory exists
gitPath := filepath.Join(root, ".git")
info, err := os.Stat(gitPath)
if err != nil {
t.Errorf("Expected .git directory at %s, got error: %v", gitPath, err)
}
if !info.IsDir() {
t.Errorf("Expected .git to be a directory at %s", gitPath)
}
t.Logf("Project root: %s", root)
}
// 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 ||
+3 -65
View File
@@ -210,68 +210,6 @@ func (e *DomainError) WithField(field string) *DomainError {
return e
}
// Common domain error constructors
func InvalidLanguageError(lang string) *DomainError {
return NewDomainError(
ErrCodeInvalidLanguage,
fmt.Sprintf("Unsupported language: %s (use 'en' or 'es')", lang),
http.StatusBadRequest,
).WithField("lang")
}
func InvalidLengthError(length string) *DomainError {
return NewDomainError(
ErrCodeInvalidLength,
fmt.Sprintf("Unsupported length: %s (use 'short' or 'long')", length),
http.StatusBadRequest,
).WithField("length")
}
func InvalidIconsError(icons string) *DomainError {
return NewDomainError(
ErrCodeInvalidIcons,
fmt.Sprintf("Unsupported icons option: %s (use 'show' or 'hide')", icons),
http.StatusBadRequest,
).WithField("icons")
}
func InvalidThemeError(theme string) *DomainError {
return NewDomainError(
ErrCodeInvalidTheme,
fmt.Sprintf("Unsupported theme: %s (use 'default' or 'clean')", theme),
http.StatusBadRequest,
).WithField("theme")
}
func InvalidVersionError(version string) *DomainError {
return NewDomainError(
ErrCodeInvalidVersion,
fmt.Sprintf("Unsupported version: %s (use 'with_skills' or 'clean')", version),
http.StatusBadRequest,
).WithField("version")
}
func PDFGenerationError(err error) *DomainError {
return NewDomainError(
ErrCodePDFGeneration,
"Failed to generate PDF",
http.StatusInternalServerError,
).WithError(err)
}
func MethodNotAllowedError(method string) *DomainError {
return NewDomainError(
ErrCodeMethodNotAllowed,
fmt.Sprintf("Method %s not allowed", method),
http.StatusMethodNotAllowed,
)
}
func RateLimitError() *DomainError {
return NewDomainError(
ErrCodeRateLimitExceeded,
"Rate limit exceeded. Please try again later.",
http.StatusTooManyRequests,
)
}
// NOTE: Domain error constructors were removed as they were unused.
// If needed in the future, they can be re-added following the DomainError pattern above.
// See git history for the previous implementation.
-15
View File
@@ -84,21 +84,6 @@ func ParsePDFExportRequest(r *http.Request) (*PDFExportRequest, error) {
return req, nil
}
// PreferenceToggleRequest represents a toggle request with language context
type PreferenceToggleRequest struct {
Lang string // Current language from query or cookie
}
// ParsePreferenceToggleRequest parses toggle request parameters
func ParsePreferenceToggleRequest(r *http.Request, defaultLang string) *PreferenceToggleRequest {
lang := r.URL.Query().Get("lang")
if lang == "" {
lang = defaultLang
}
return &PreferenceToggleRequest{Lang: lang}
}
// ==============================================================================
// RESPONSE TYPES
// Structured response types for consistent API responses