feat: add origin validation and rate limiting for PDF endpoint
Security enhancements: - Implement origin/referer validation middleware - Add rate limiting (3 requests/min per IP) - Default to production domain (juan.andres.morenorub.io) - Verify all protection mechanisms working correctly Documentation updates: - Update README to reflect personal portfolio nature - Remove template encouragement from README - Add verification status to API-PROTECTION.md - Document ALLOWED_ORIGINS configuration in .env.example Cleanup: - Remove templates.backup/ folder - Remove old test screenshots
This commit is contained in:
+6
-2
@@ -21,8 +21,12 @@ WRITE_TIMEOUT=15
|
||||
# Security Configuration
|
||||
# Allowed origins for API access (comma-separated domains)
|
||||
# Prevents external sites from accessing your API/PDF endpoint
|
||||
# Leave empty for development (allows localhost)
|
||||
# Example for production: ALLOWED_ORIGINS=yourdomain.com,www.yourdomain.com
|
||||
#
|
||||
# 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=
|
||||
|
||||
# Production Settings
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
[](https://go.dev/)
|
||||
[](https://htmx.org/)
|
||||
[](LICENSE)
|
||||
[](#-customization)
|
||||
|
||||
**Modern, minimal curriculum vitae website** for Juan Andrés Moreno Rubio built with **Go** and **HTMX**.
|
||||
|
||||
A professional, bilingual CV site with server-side PDF generation, HTMX interactivity, and a clean paper design aesthetic. Perfect template for developers looking to create their own CV website with modern tech and minimal JavaScript.
|
||||
A professional, bilingual CV site with server-side PDF generation, HTMX interactivity, and a clean paper design aesthetic. Built as a personal portfolio project showcasing production-grade Go and HTMX development.
|
||||
|
||||
## 📌 Project Status
|
||||
|
||||
**This is a portfolio/showcase project** demonstrating production-grade Go and HTMX development.
|
||||
**This is a personal portfolio project** demonstrating production-grade Go and HTMX development.
|
||||
|
||||
**Template Usage:** Feel free to fork and customize this CV template for your own use following the [CUSTOMIZATION.md](CUSTOMIZATION.md) guide.
|
||||
**Open Source:** The code is MIT licensed and available for educational purposes. While you may use it as reference or inspiration, this repository is maintained as my personal CV site and may be modified without notice.
|
||||
|
||||
**Contributions:** This is a personal CV project and is feature-complete. I'm not actively seeking contributions, but you're welcome to use this as a template for your own CV! If you find a critical security vulnerability, please follow the [SECURITY.md](SECURITY.md) process.
|
||||
**Contributions:** This is a personal CV project and is feature-complete. I'm not seeking contributions. If you find a critical security vulnerability, please follow the [SECURITY.md](SECURITY.md) process.
|
||||
|
||||
## 📑 Table of Contents
|
||||
|
||||
@@ -43,7 +42,7 @@ A professional, bilingual CV site with server-side PDF generation, HTMX interact
|
||||
- ✅ **JSON-Based Content** - Easy to update without touching code
|
||||
- ✅ **AI Development Section** - Showcases modern AI-assisted development skills
|
||||
- ✅ **Fast & Lightweight** - Go backend with chromedp for PDF generation
|
||||
- ✅ **Security Hardened** - CSP headers, XSS protection, secure defaults
|
||||
- ✅ **Security Hardened** - CSP headers, XSS protection, origin validation, rate limiting
|
||||
- ✅ **Production Ready** - Systemd service, CI/CD workflows, deployment guides
|
||||
- ✅ **Developer Friendly** - Hot reload, clear code structure, comprehensive Makefile
|
||||
|
||||
@@ -57,21 +56,23 @@ A professional, bilingual CV site with server-side PDF generation, HTMX interact
|
||||
- Clean paper aesthetic on gray background
|
||||
- Print-friendly layouts
|
||||
|
||||
**Note:** This is a personal CV site template. Fork it and customize the JSON files with your own information!
|
||||
**Note:** This is my personal CV site. The code is open source for learning and reference purposes.
|
||||
|
||||
## 📋 Quick Start
|
||||
## 📋 Running Locally
|
||||
|
||||
If you want to explore the code or run it locally:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go 1.21+** installed
|
||||
- **Chrome/Chromium** (for PDF generation)
|
||||
- **Make** (optional, recommended for easier development)
|
||||
- **Make** (optional, for easier development)
|
||||
|
||||
### Installation & Run
|
||||
### Local Development
|
||||
|
||||
\`\`\`bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/cv.git
|
||||
# Download the code
|
||||
git clone https://github.com/txemac/cv.git
|
||||
cd cv
|
||||
|
||||
# Option 1: Using Make (recommended)
|
||||
|
||||
@@ -4,6 +4,27 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ VERIFICATION STATUS
|
||||
|
||||
**Last Tested:** November 9, 2025
|
||||
**Status:** ✅ **ALL PROTECTION MECHANISMS VERIFIED WORKING**
|
||||
|
||||
### Verified Test Results
|
||||
|
||||
| Test | Expected | Actual | Status |
|
||||
|------|----------|--------|--------|
|
||||
| External referer (evil.com) | 403 Forbidden | 403 Forbidden | ✅ PASS |
|
||||
| Localhost referer | 200 OK | 200 OK | ✅ PASS |
|
||||
| Production domain referer | 200 OK | 200 OK | ✅ PASS |
|
||||
| External Origin header | 403 Forbidden | 403 Forbidden | ✅ PASS |
|
||||
| No referer (development) | 200 OK | 200 OK | ✅ PASS |
|
||||
| Rate limit (requests 1-3) | 200 OK | 200 OK | ✅ PASS |
|
||||
| Rate limit (request 4+) | 429 Too Many | 429 Too Many | ✅ PASS |
|
||||
|
||||
**Protection Layers:** Origin checking + Rate limiting both working correctly.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The CV website implements multiple layers of protection to prevent external sites from accessing the API and to protect against DDoS attacks on resource-intensive endpoints like PDF generation.
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
<!-- Professional Title Badges - Full Width Top Bar -->
|
||||
<div class="cv-title-badges-header">
|
||||
<span class="title-badge">{{if eq .Lang "es"}}ANALISTA PROGRAMADOR{{else}}ANALYST PROGRAMMER{{end}}</span>
|
||||
<span class="badge-separator">|</span>
|
||||
<span class="title-badge">NODEJS + REACTJS {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
|
||||
<span class="badge-separator">|</span>
|
||||
<span class="title-badge">WEB {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
|
||||
<span class="badge-separator">|</span>
|
||||
<span class="title-badge">GO {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
|
||||
<span class="badge-separator">|</span>
|
||||
<span class="title-badge">PHP {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Left Sidebar - Skills -->
|
||||
<aside class="cv-sidebar">
|
||||
<!-- Skills Section - Dynamically render all categories -->
|
||||
{{range .CV.Skills.Technical}}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{.Category}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{range .Items}}<div class="skill-item">{{.}}</div>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Languages Section -->
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{if eq .Lang "es"}}Idiomas{{else}}Languages{{end}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{range .CV.Languages}}
|
||||
<div class="language-item">
|
||||
<strong>{{.Language}}</strong>: {{.Proficiency}}
|
||||
{{if .Detail}}<br><small style="color: #666;">{{.Detail}}</small>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Courses Section -->
|
||||
{{if .CV.Courses}}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{if eq .Lang "es"}}Cursos Realizados{{else}}Training Courses{{end}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{range .CV.Courses}}
|
||||
<div class="course-item">
|
||||
<strong>{{.Title}}</strong><br>
|
||||
<small>{{.Institution}} - {{.Location}}</small><br>
|
||||
<small>{{.Date}} ({{.Duration}})</small>
|
||||
{{if .Description}}<p class="course-desc">{{.Description}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- References Section -->
|
||||
{{if .CV.References}}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{if eq .Lang "es"}}Referencias{{else}}References{{end}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{range .CV.References}}
|
||||
<div class="reference-item">
|
||||
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a>
|
||||
<small class="ref-type">({{.Type}})</small>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Other Section (Driver's License) -->
|
||||
{{if .CV.Other.DriverLicense}}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{if eq .Lang "es"}}Otros{{else}}Other{{end}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{if eq .Lang "es"}}Carnet de conducir {{.CV.Other.DriverLicense}}{{else}}Driver's License {{.CV.Other.DriverLicense}}{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="cv-main">
|
||||
<!-- Header with Name and Photo -->
|
||||
<div class="cv-header">
|
||||
<div class="cv-header-content">
|
||||
<div class="cv-header-left">
|
||||
<h1 class="cv-name">{{.CV.Personal.Name}}</h1>
|
||||
<p class="cv-experience-years">{{if eq .Lang "es"}}20 años de experiencia{{else}}20 years of experience{{end}}</p>
|
||||
<!-- Intro/Excerpt Text - No section heading, just the text -->
|
||||
<div class="intro-text">{{.CV.Summary}}</div>
|
||||
</div>
|
||||
<div class="cv-photo">
|
||||
<img src="/static/images/profile/photo.jpg" alt="{{.CV.Personal.Name}}" onerror="this.src='/static/images/profile/placeholder.svg'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Education -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Formación{{else}}Training{{end}}</h3>
|
||||
{{range .CV.Education}}
|
||||
<div class="education-item">
|
||||
<strong>{{.Degree}}</strong> ({{.StartDate}}-{{.EndDate}}) {{if eq $.Lang "es"}}obtenido de{{else}}obtained from the{{end}} <strong>{{.Institution}}</strong> ({{.Location}})
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Skills Summary -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}</h3>
|
||||
<p class="summary-text">
|
||||
{{if eq .Lang "es"}}
|
||||
Amplio conocimiento en entornos web, tanto J2EE como PHP. Experto en tecnologías front-end, aunque con considerable experiencia en sistemas back-end. Receptivo al aprendizaje de nuevas tecnologías, y con una gran dosis de creatividad. Capacidad de analizar problemas y aportar soluciones específicas adaptadas a cada tipo de cliente. Me gusta trabajar tanto solo como en grupos.
|
||||
{{else}}
|
||||
Extensive knowledge in web environments, both J2EE and PHP. Expert in front-end technologies, although with considerable experience in back-end systems. Receptive to learning new technologies, and with a large dose of creativity. Ability to analyze problems and provide specific solutions tailored to each client type. I like to work both alone and in groups.
|
||||
{{end}}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Experience -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}</h3>
|
||||
|
||||
{{range .CV.Experience}}
|
||||
<div class="experience-item">
|
||||
<div class="experience-header">
|
||||
<div class="experience-title-line">
|
||||
<h4 class="position">{{.Position}} / {{if eq $.Lang "es"}}Analista Programador{{else}}Analyst Programmer{{end}}</h4>
|
||||
<span class="experience-period">{{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}} - ({{.Location}})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ShortDescription}}
|
||||
<p class="short-desc">{{.ShortDescription}}</p>
|
||||
{{end}}
|
||||
|
||||
<div class="long-only">
|
||||
<ul class="responsibilities">
|
||||
{{range .Responsibilities}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
</main>
|
||||
@@ -1,171 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{if eq .Lang "es"}}es{{else}}en{{end}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{{.CV.Personal.Name}} - {{.CV.Personal.Title}}">
|
||||
<meta name="keywords" content="CV, Resume, {{.CV.Personal.Name}}, Developer, SAP, AI">
|
||||
<meta name="author" content="{{.CV.Personal.Name}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="{{.CV.Personal.Name}} - Curriculum Vitae">
|
||||
<meta property="og:description" content="{{.CV.Personal.Title}}">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:url" content="{{.CV.Personal.Website}}">
|
||||
|
||||
<title>{{.CV.Personal.Name}} - Curriculum Vitae</title>
|
||||
|
||||
<!-- HTMX with Integrity Check -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"
|
||||
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
<link rel="stylesheet" href="/static/css/print.css" media="print">
|
||||
|
||||
<!-- Fonts with Preload -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
|
||||
<!-- HTMX Configuration -->
|
||||
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Language & Export Bar (hidden in print) -->
|
||||
<div class="action-bar no-print" role="navigation" aria-label="Language and export controls">
|
||||
<div class="action-bar-content">
|
||||
<div class="language-toggle" role="group" aria-label="Language selection">
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "en"}}active{{end}}"
|
||||
hx-get="/cv?lang=en"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML swap:200ms settle:200ms"
|
||||
hx-indicator="#loading"
|
||||
hx-push-url="/?lang=en"
|
||||
hx-on::before-request="this.setAttribute('aria-busy', 'true')"
|
||||
hx-on::after-request="this.setAttribute('aria-busy', 'false')"
|
||||
aria-label="Switch to English"
|
||||
aria-pressed="{{if eq .Lang "en"}}true{{else}}false{{end}}">
|
||||
🇬🇧 English
|
||||
</button>
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "es"}}active{{end}}"
|
||||
hx-get="/cv?lang=es"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML swap:200ms settle:200ms"
|
||||
hx-indicator="#loading"
|
||||
hx-push-url="/?lang=es"
|
||||
hx-on::before-request="this.setAttribute('aria-busy', 'true')"
|
||||
hx-on::after-request="this.setAttribute('aria-busy', 'false')"
|
||||
aria-label="Switch to Spanish"
|
||||
aria-pressed="{{if eq .Lang "es"}}true{{else}}false{{end}}">
|
||||
🇪🇸 Español
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="export-actions">
|
||||
<button
|
||||
class="export-btn"
|
||||
onclick="window.print()"
|
||||
aria-label="{{if eq .Lang "es"}}Descargar PDF del CV{{else}}Download CV as PDF{{end}}">
|
||||
📄 {{if eq .Lang "es"}}Descargar PDF{{else}}Download PDF{{end}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span id="loading" class="htmx-indicator" role="status" aria-live="polite" aria-label="Loading">
|
||||
<span class="loader" aria-hidden="true"></span>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CV Content Container -->
|
||||
<div class="cv-container">
|
||||
<main id="cv-content"
|
||||
class="cv-paper"
|
||||
role="main"
|
||||
aria-live="polite"
|
||||
aria-atomic="false">
|
||||
{{template "cv-content.html" .}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Error Toast (hidden by default) -->
|
||||
<div id="error-toast" class="error-toast no-print" role="alert" aria-live="assertive" style="display: none;">
|
||||
<span id="error-message"></span>
|
||||
<button onclick="this.parentElement.style.display='none'" aria-label="Close error message">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer (hidden in print) -->
|
||||
<footer class="no-print" role="contentinfo">
|
||||
<p>© {{.CV.Meta.LastUpdated}} {{.CV.Personal.Name}} |
|
||||
{{if eq .Lang "es"}}Última actualización{{else}}Last updated{{end}}: {{.CV.Meta.LastUpdated}}</p>
|
||||
</footer>
|
||||
|
||||
<!-- HTMX Event Handlers -->
|
||||
<script>
|
||||
// Global error handler for HTMX requests
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
const errorToast = document.getElementById('error-toast');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
errorMessage.textContent = '{{if eq .Lang "es"}}Error al cargar el contenido. Por favor, inténtelo de nuevo.{{else}}Failed to load content. Please try again.{{end}}';
|
||||
errorToast.style.display = 'flex';
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
errorToast.style.display = 'none';
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Smooth scroll to top on language change
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.detail.target.id === 'cv-content') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
// Save language preference
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const url = new URL(evt.detail.xhr.responseURL);
|
||||
const lang = url.searchParams.get('lang');
|
||||
if (lang) {
|
||||
localStorage.setItem('cv-lang', lang);
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved language preference on page load
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
const savedLang = localStorage.getItem('cv-lang');
|
||||
const currentLang = '{{.Lang}}';
|
||||
if (savedLang && savedLang !== currentLang) {
|
||||
document.querySelector(`[hx-get="/cv?lang=${savedLang}"]`)?.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function(evt) {
|
||||
// Ctrl/Cmd + P for print
|
||||
if ((evt.ctrlKey || evt.metaKey) && evt.key === 'p') {
|
||||
evt.preventDefault();
|
||||
window.print();
|
||||
}
|
||||
// Ctrl/Cmd + E for English, Ctrl/Cmd + S for Spanish
|
||||
if (evt.ctrlKey || evt.metaKey) {
|
||||
if (evt.key === 'e') {
|
||||
evt.preventDefault();
|
||||
document.querySelector('[hx-get="/cv?lang=en"]')?.click();
|
||||
} else if (evt.key === 's' && evt.shiftKey) {
|
||||
evt.preventDefault();
|
||||
document.querySelector('[hx-get="/cv?lang=es"]')?.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,274 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{if eq .Lang "es"}}es{{else}}en{{end}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{{.CV.Personal.Name}} - {{if eq .Lang "es"}}Curriculum Vitae{{else}}Curriculum Vitae{{end}}</title>
|
||||
<meta name="title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
|
||||
<meta name="description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}18 años de experiencia en desarrollo web, SAP CDC, React, Node.js, Go, HTMX y desarrollo asistido por IA{{else}}18 years of experience in web development, SAP CDC, React, Node.js, Go, HTMX and AI-assisted development{{end}}">
|
||||
<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}}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:url" content="{{.CV.Personal.Website}}">
|
||||
<meta property="og:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
|
||||
<meta property="og:description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}Consultor Técnico Senior con 18 años de experiencia{{else}}Senior Technical Consultant with 18 years of experience{{end}}">
|
||||
<meta property="og:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
|
||||
<meta property="og:locale" content="{{if eq .Lang "es"}}es_ES{{else}}en_US{{end}}">
|
||||
<meta property="og:site_name" content="{{.CV.Personal.Name}}">
|
||||
<meta property="profile:first_name" content="Juan Andrés">
|
||||
<meta property="profile:last_name" content="Moreno Rubio">
|
||||
<meta property="profile:username" content="txeo">
|
||||
|
||||
<!-- Social Media Card (Generic) -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
|
||||
<meta name="twitter:description" content="{{.CV.Personal.Title}}">
|
||||
<meta name="twitter:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
|
||||
|
||||
<!-- HTMX Configuration -->
|
||||
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
|
||||
|
||||
<!-- HTMX with SRI (Subresource Integrity) -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"
|
||||
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
<link rel="stylesheet" href="/static/css/print.css" media="print">
|
||||
|
||||
<!-- Fonts with Preload -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Structured Data (JSON-LD) -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "{{.CV.Personal.Name}}",
|
||||
"jobTitle": "{{.CV.Personal.Title}}",
|
||||
"url": "{{.CV.Personal.Website}}",
|
||||
"email": "{{.CV.Personal.Email}}",
|
||||
"telephone": "{{.CV.Personal.Phone}}",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "{{.CV.Personal.Location}}"
|
||||
},
|
||||
"sameAs": [
|
||||
"{{.CV.Personal.LinkedIn}}",
|
||||
"{{.CV.Personal.GitHub}}",
|
||||
"{{.CV.Personal.Behance}}"
|
||||
],
|
||||
"alumniOf": {
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "Universidad de Extremadura"
|
||||
},
|
||||
"knowsAbout": [
|
||||
"Web Development",
|
||||
"SAP Customer Data Cloud",
|
||||
"React",
|
||||
"Node.js",
|
||||
"Go",
|
||||
"HTMX",
|
||||
"AI-Assisted Development",
|
||||
"Full Stack Development"
|
||||
],
|
||||
"worksFor": {
|
||||
"@type": "Organization",
|
||||
"name": "Olympic Broadcasting Services"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Single Black Bar with Everything -->
|
||||
<div class="action-bar no-print" role="navigation" aria-label="Language and export controls">
|
||||
<div class="action-bar-content">
|
||||
<!-- Left: Language buttons -->
|
||||
<div class="language-toggle" role="group" aria-label="Language selection">
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "en"}}active{{end}}"
|
||||
hx-get="/cv?lang=en"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML swap:200ms settle:200ms"
|
||||
hx-push-url="/?lang=en"
|
||||
hx-indicator="#loading"
|
||||
aria-label="Switch to English"
|
||||
aria-pressed="{{if eq .Lang "en"}}true{{else}}false{{end}}">
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "es"}}active{{end}}"
|
||||
hx-get="/cv?lang=es"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML swap:200ms settle:200ms"
|
||||
hx-push-url="/?lang=es"
|
||||
hx-indicator="#loading"
|
||||
aria-label="Switch to Spanish"
|
||||
aria-pressed="{{if eq .Lang "es"}}true{{else}}false{{end}}">
|
||||
Español
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Center: CV Length Toggle -->
|
||||
<div class="cv-length-toggle">
|
||||
<button
|
||||
class="length-btn active"
|
||||
onclick="toggleCVLength('short')"
|
||||
aria-label="{{if eq .Lang "es"}}Ver CV corto{{else}}View short CV{{end}}">
|
||||
{{if eq .Lang "es"}}Corto{{else}}Short{{end}}
|
||||
</button>
|
||||
<button
|
||||
class="length-btn"
|
||||
onclick="toggleCVLength('long')"
|
||||
aria-label="{{if eq .Lang "es"}}Ver CV largo{{else}}View long CV{{end}}">
|
||||
{{if eq .Lang "es"}}Largo{{else}}Long{{end}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right: Action buttons -->
|
||||
<div class="action-buttons">
|
||||
<a
|
||||
class="export-btn"
|
||||
href="/export/pdf?lang={{.Lang}}"
|
||||
download
|
||||
aria-label="{{if eq .Lang "es"}}Descargar PDF del CV{{else}}Download CV as PDF{{end}}">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="display: inline-block; vertical-align: middle;">
|
||||
<path d="M8.5 11.5l3.5-3.5h-2.5v-6h-2v6h-2.5l3.5 3.5zm-6.5 2.5v2h12v-2h-12z"/>
|
||||
</svg>
|
||||
{{if eq .Lang "es"}}Descargar PDF{{else}}Download as PDF{{end}}
|
||||
</a>
|
||||
<button
|
||||
class="export-btn"
|
||||
onclick="window.print()"
|
||||
aria-label="{{if eq .Lang "es"}}Imprimir CV{{else}}Print CV{{end}}">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="display: inline-block; vertical-align: middle;">
|
||||
<path d="M14 4h-3v-3h-6v3h-3c-1.1 0-2 0.9-2 2v5h3v4h8v-4h3v-5c0-1.1-0.9-2-2-2zm-7-2h2v2h-2v-2zm5 11h-8v-5h8v5zm2-7c-0.552 0-1-0.448-1-1s0.448-1 1-1 1 0.448 1 1-0.448 1-1 1z"/>
|
||||
</svg>
|
||||
{{if eq .Lang "es"}}Imprimir{{else}}Print Friendly{{end}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span id="loading"
|
||||
class="htmx-indicator"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading">
|
||||
<span class="loader"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CV Content Container -->
|
||||
<div class="cv-container">
|
||||
<main id="cv-content"
|
||||
class="cv-paper"
|
||||
role="main"
|
||||
aria-live="polite">
|
||||
{{template "cv-content.html" .}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer (hidden in print) -->
|
||||
<footer class="no-print">
|
||||
<p>© {{.CV.Meta.LastUpdated}} {{.CV.Personal.Name}} |
|
||||
{{if eq .Lang "es"}}Última actualización{{else}}Last updated{{end}}: {{.CV.Meta.LastUpdated}}</p>
|
||||
</footer>
|
||||
|
||||
<!-- Error Toast -->
|
||||
<div id="error-toast" class="error-toast no-print" role="alert" aria-live="assertive" style="display: none;">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span id="error-message"></span>
|
||||
<button onclick="this.parentElement.style.display='none'" aria-label="Close error message" class="error-close">×</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleCVLength(length) {
|
||||
// Update button states
|
||||
document.querySelectorAll('.length-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Toggle visibility
|
||||
const paper = document.querySelector('.cv-paper');
|
||||
if (length === 'short') {
|
||||
paper.classList.add('cv-short');
|
||||
paper.classList.remove('cv-long');
|
||||
} else {
|
||||
paper.classList.add('cv-long');
|
||||
paper.classList.remove('cv-short');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize with short version
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelector('.cv-paper').classList.add('cv-short');
|
||||
});
|
||||
|
||||
// Error handling utility
|
||||
function showError(message) {
|
||||
const errorToast = document.getElementById('error-toast');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
errorMessage.textContent = message;
|
||||
errorToast.style.display = 'flex';
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
errorToast.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// HTMX Global Error Handlers
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
console.error('HTMX Response Error:', evt.detail);
|
||||
const lang = document.documentElement.lang;
|
||||
const message = lang === 'es'
|
||||
? 'Error al cargar el contenido. Por favor, inténtelo de nuevo.'
|
||||
: 'Failed to load content. Please try again.';
|
||||
showError(message);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:sendError', function(evt) {
|
||||
console.error('HTMX Send Error:', evt.detail);
|
||||
const lang = document.documentElement.lang;
|
||||
const message = lang === 'es'
|
||||
? 'Error de conexión. Verifique su conexión a internet.'
|
||||
: 'Connection error. Please check your internet connection.';
|
||||
showError(message);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:timeout', function(evt) {
|
||||
console.error('HTMX Timeout:', evt.detail);
|
||||
const lang = document.documentElement.lang;
|
||||
const message = lang === 'es'
|
||||
? 'La solicitud tardó demasiado. Por favor, inténtelo de nuevo.'
|
||||
: 'Request timed out. Please try again.';
|
||||
showError(message);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
// Smooth scroll to top on language change
|
||||
if (evt.detail.target.id === 'cv-content') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
// Log successful swaps for debugging
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.successful) {
|
||||
console.log('HTMX request successful:', evt.detail.pathInfo.requestPath);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
# Placeholder for future partial templates
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.3 KiB |
Reference in New Issue
Block a user