Files
cv-site/templates/index.html
T
juanatsap 58c1237326 feat: Add secure contact form with comprehensive security features
- Add contact form dialog with HTMX integration (hx-post)
- Implement browser-only access middleware (blocks curl/Postman/wget)
- Add rate limiting (5 requests/hour per IP) for contact endpoint
- Implement honeypot and timing-based bot detection
- Add input validation (email format, message length 10-5000 chars)
- Create contact button in desktop and mobile navigation (last position)

Security features:
- Browser-only middleware validates User-Agent, Referer/Origin, HX-Request headers
- Honeypot field returns fake success to fool bots while logging spam
- Timing validation rejects forms submitted < 2 seconds
- All security events logged for monitoring

Documentation:
- docs/SECURITY.md - Comprehensive security documentation
- docs/HACK-CHALLENGE.md - "Try to Hack Me!" challenge for security researchers
- docs/SECURITY-AUDIT-REPORT.md - Full security audit report
- docs/CONTACT-FORM-QUICKSTART.md - Integration guide

Form fields: email (required), name, company, subject, message (required)
2025-11-30 14:31:58 +00:00

406 lines
15 KiB
HTML

<!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}} - {{.CV.SEO.PageTitle}}</title>
<meta name="title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}">
<meta name="description" content="{{.CV.Personal.Title}} | {{.CV.SEO.MetaDescription}}">
<meta name="keywords" content="{{.CV.Personal.Name}}, {{.CV.SEO.Keywords}}">
<meta name="author" content="{{.CV.Personal.Name}}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{.CanonicalURL}}">
<!-- Hreflang tags for international SEO -->
<link rel="alternate" hreflang="en" href="{{.AlternateEN}}">
<link rel="alternate" hreflang="es" href="{{.AlternateES}}">
<link rel="alternate" hreflang="x-default" href="https://juan.andres.morenorub.io/?lang=en">
<!-- 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}} - {{.CV.SEO.MetaTitle}}">
<meta property="og:description" content="{{.CV.Personal.Title}} | {{.CV.SEO.OgDescription}}">
<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="{{.CV.Personal.FirstName}}">
<meta property="profile:last_name" content="{{.CV.Personal.LastName}}">
<meta property="profile:username" content="{{.CV.Personal.Username}}">
<!-- Social Media Card (Generic) -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}">
<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}'>
<!-- FOUC Prevention: Inline critical CSS + Apply color theme before page render -->
<!-- Critical theme variables inlined to prevent flash of unstyled content -->
<style>
/* Light theme (default) - critical variables only */
:root {
--page-bg: #d6d6d6;
--paper-bg: #ffffff;
--text-primary: #1a1a1a;
--sidebar-bg: #d1d4d2;
}
/* Dark theme - critical variables only */
[data-color-theme="dark"] {
--page-bg: #3a3a3a;
--paper-bg: #1a1a1a;
--text-primary: #e0e0e0;
--sidebar-bg: #3a3d3e;
}
/* Auto theme follows system preference */
@media (prefers-color-scheme: dark) {
[data-color-theme="auto"] {
--page-bg: #3a3a3a;
--paper-bg: #1a1a1a;
--text-primary: #e0e0e0;
--sidebar-bg: #3a3d3e;
}
}
/* Apply critical styles immediately */
html { background-color: var(--page-bg); }
body { color: var(--text-primary); }
</style>
<script>
(function() {
// First-time visitors ALWAYS get light theme (paper aesthetic)
// Users can switch to dark/auto and their preference is saved
let theme = localStorage.getItem('color-theme-mode') || 'light';
document.documentElement.setAttribute('data-color-theme', theme);
})();
</script>
<!-- Device Detection - Detect real mobile devices vs desktop browser -->
<script src="/static/js/device-detection.js"></script>
<!-- HTMX with SRI (Subresource Integrity) -->
<script src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"></script>
<!-- Hyperscript Functions - Must load BEFORE hyperscript library -->
<!-- NOTE: cv-functions.js removed - hyperscript def statements are globally available -->
<!-- ✅ NO def limit with latest hyperscript - organized by category -->
<script type="text/hyperscript" src="/static/hyperscript/utils._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/toggles._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/hover-sync._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/keyboard._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/zoom._hs"></script>
<script type="text/hyperscript" src="/static/hyperscript/pdf-modal._hs"></script>
<!-- Color Theme System (JavaScript - hyperscript had parsing issues with colons in strings) -->
<script src="/static/js/color-theme.js"></script>
<!-- NOTE: footer-buttons-interaction.js removed - moved to hyperscript on footer element -->
<!-- NOTE: scroll-at-bottom-handler.js removed - duplicate of handleScroll() in utils._hs -->
<!-- Hyperscript - Declarative event handling for enhanced interactivity -->
<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
<!-- Iconify - Load synchronously for immediate rendering -->
<!-- Using unpkg CDN (more reliable than code.iconify.design) -->
<script src="https://cdn.jsdelivr.net/npm/iconify-icon@2.1.0/dist/iconify-icon.min.js"></script>
<!-- CSS - Conditional loading: bundled in production, modular in development -->
{{if .IsProduction}}
<link rel="stylesheet" href="/static/dist/bundle.min.css">
{{else}}
<link rel="stylesheet" href="/static/css/main.css">
{{end}}
<!-- Print styles - loaded separately, only applied when printing -->
<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=Quicksand:wght@300;400;500;600;700&family=Source+Sans+Pro:wght@300;400;600&display=swap" rel="stylesheet">
<!-- Structured Data (JSON-LD) - Enhanced for AI-era SEO -->
<!-- Person Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"@id": "{{.CV.Personal.Website}}/#person",
"name": "{{.CV.Personal.Name}}",
"givenName": "{{.CV.Personal.FirstName}}",
"familyName": "{{.CV.Personal.LastName}}",
"jobTitle": "{{.CV.Personal.Title}}",
"description": "{{.CV.Summary}}",
"url": "{{.CV.Personal.Website}}",
"image": "{{.CV.Personal.Website}}/static/images/profile.jpg",
"email": "{{.CV.Personal.Email}}",
"telephone": "{{.CV.Personal.Phone}}",
"birthDate": "{{.CV.Personal.DateOfBirth}}",
"birthPlace": {
"@type": "Place",
"name": "{{.CV.Personal.PlaceOfBirth}}"
},
"nationality": {
"@type": "Country",
"name": "{{.CV.Personal.Citizenship}}"
},
"address": {
"@type": "PostalAddress",
"addressLocality": "{{.CV.Personal.Location}}",
"addressCountry": "ES"
},
"sameAs": [
"{{.CV.Personal.LinkedIn}}",
"{{.CV.Personal.GitHub}}",
"{{.CV.Personal.Domestika}}"
],
"alumniOf": {
"@type": "CollegeOrUniversity",
"name": "Universidad de Extremadura",
"address": {
"@type": "PostalAddress",
"addressLocality": "Cáceres",
"addressCountry": "ES"
}
},
"knowsAbout": [
"Web Development",
"SAP Customer Data Cloud",
"React",
"Node.js",
"Go",
"HTMX",
"AI-Assisted Development",
"Full Stack Development",
"Authentication Systems",
"GDPR Compliance",
"Identity Management"
],
"knowsLanguage": [
{
"@type": "Language",
"name": "Spanish",
"alternateName": "es"
},
{
"@type": "Language",
"name": "English",
"alternateName": "en"
},
{
"@type": "Language",
"name": "Portuguese",
"alternateName": "pt"
}
],
"worksFor": [
{
"@type": "Organization",
"name": "Olympic Broadcasting Services",
"url": "https://www.obs.tv/"
},
{
"@type": "Organization",
"name": "LIV Golf",
"url": "https://www.livgolf.com/"
}
],
"hasOccupation": [
{{- range $i, $exp := .CV.Experience}}{{if $i}},{{end}}
{
"@type": "Occupation",
"name": "{{$exp.Position}}",
"occupationLocation": {
"@type": "Place",
"name": "{{$exp.Location}}"
},
"description": "{{$exp.ShortDescription}}",
"skills": "{{range $j, $tech := $exp.Technologies}}{{if $j}}, {{end}}{{$tech}}{{end}}"
}
{{- end}}
]
}
</script>
<!-- WebSite Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "{{.CV.Personal.Website}}/#website",
"name": "{{.CV.Personal.Name}} - Professional CV",
"url": "{{.CV.Personal.Website}}",
"description": "Interactive curriculum vitae of {{.CV.Personal.Name}}, {{.CV.Personal.Title}}",
"inLanguage": ["en", "es"],
"author": {
"@id": "{{.CV.Personal.Website}}/#person"
},
"potentialAction": {
"@type": "SearchAction",
"target": "{{.CV.Personal.Website}}/?lang={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<!-- BreadcrumbList Schema -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "{{.CV.Personal.Website}}"
},
{
"@type": "ListItem",
"position": 2,
"name": "CV {{if eq .Lang "es"}}(Español){{else}}(English){{end}}",
"item": "{{.CV.Personal.Website}}/?lang={{.Lang}}"
}
]
}
</script>
<!-- ProfilePage Schema (for CV/Resume) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfilePage",
"mainEntity": {
"@id": "{{.CV.Personal.Website}}/#person"
},
"dateCreated": "2024-01-01",
"dateModified": "{{.CV.Meta.LastUpdated}}",
"name": "{{.CV.Personal.Name}} - Curriculum Vitae",
"description": "{{.CV.SEO.MetaDescription}}",
"inLanguage": "{{.Lang}}"
}
</script>
<!-- EducationalOccupationalCredential Schema -->
{{range .CV.Education}}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "EducationalOccupationalCredential",
"name": "{{.Degree}}",
"description": "{{.Field}}",
"educationalLevel": "Bachelor's Degree",
"credentialCategory": "degree",
"recognizedBy": {
"@type": "CollegeOrUniversity",
"name": "{{.Institution}}",
"address": {
"@type": "PostalAddress",
"addressLocality": "{{.Location}}"
}
},
"dateCreated": "{{.EndDate}}"
}
</script>
{{end}}
<!-- Course Schemas -->
{{range .CV.Courses}}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Course",
"name": "{{.Title}}",
"description": "{{if .ShortDescription}}{{.ShortDescription}}{{else}}{{.Description}}{{end}}",
"provider": {
"@type": "Organization",
"name": "{{.Institution}}"
},
"hasCourseInstance": {
"@type": "CourseInstance",
"courseMode": "onsite",
"location": {
"@type": "Place",
"name": "{{.Location}}"
}
},
"timeRequired": "{{.Duration}}"
}
</script>
{{end}}
</head>
<body {{if .ThemeClean}}class="theme-clean"{{end}}
_="on load call initScrollBehavior()
on scroll from window call handleScroll()
on keydown
set tag to event.target.tagName
set skip to (tag is 'INPUT' or tag is 'TEXTAREA')
set noMod to (not event.ctrlKey and not event.metaKey and not event.altKey)
if event.key is '?' and noMod and not skip then halt the event then call openModalShortcut('shortcuts-modal') end
if (event.key is 'l' or event.key is 'L') and noMod and not skip then halt the event then call handleToggleShortcut('lengthToggle', 'lengthToggleMenu') end
if (event.key is 'i' or event.key is 'I') and noMod and not skip then halt the event then call handleToggleShortcut('iconToggle', 'iconToggleMenu') end
if (event.key is 'v' or event.key is 'V') and noMod and not skip then halt the event then call handleToggleShortcut('themeToggle', 'themeToggleMenu') end
end">
<!-- Top anchor for back-to-top link -->
<div id="top"></div>
{{template "action-bar" .}}
{{template "hamburger-menu" .}}
{{template "color-theme-switcher" .}}
<!-- Zoom Wrapper (for zoom functionality) -->
<div id="zoom-wrapper" class="zoom-wrapper">
<!-- CV Content Container -->
<div class="cv-container">
{{template "cv-content.html" .}}
</div>
</div> <!-- End zoom-wrapper -->
{{template "page-footer" .}}
{{template "error-toast" .}}
{{template "pdf-toast" .}}
<!-- iOS-style blur backdrop for mobile buttons -->
<div class="fixed-buttons-backdrop no-print"></div>
{{template "back-to-top" .}}
{{template "info-button" .}}
{{template "download-button" .}}
{{template "print-friendly-button" .}}
{{template "zoom-toggle-button" .}}
{{template "shortcuts-button" .}}
{{template "info-modal" .}}
{{template "shortcuts-modal" .}}
{{template "pdf-modal" .}}
{{template "contact-modal" .}}
{{template "zoom-control" .}}
<!-- External JavaScript - CSP Compliant -->
<script src="/static/js/main.js"></script>
<!-- Matomo Analytics - First-party subdomain to bypass ad blockers -->
<script>
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="https://matomo.morenorub.io/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '4']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true;
g.src=u+'matomo.js';
s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Matomo Code -->
</body>
</html>