diff --git a/.env.example b/.env.example index dbf2087..727271a 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,19 @@ ALLOWED_ORIGINS= BEHIND_PROXY=false TRUSTED_PROXY_IP= +# Email Configuration (Contact Form) +# For Gmail: +# 1. Enable 2FA in your Google account +# 2. Go to https://myaccount.google.com/apppasswords +# 3. Generate an App Password +# 4. Use that password here (not your regular Gmail password) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-app-password-here +SMTP_FROM_EMAIL=your-email@gmail.com +CONTACT_EMAIL=txeo.msx@gmail.com + # Production Settings # Uncomment for production: # GO_ENV=production diff --git a/data/ui-en.json b/data/ui-en.json index 2ebac50..d2d3a36 100644 --- a/data/ui-en.json +++ b/data/ui-en.json @@ -159,6 +159,34 @@ "viewSource": "View Project in Github", "viewSourceSubtext": "Want to know how it's built?" }, + "contactModal": { + "title": "Get in Touch", + "subtitle": "Let's connect!", + "description": "Have a question or interested in working together? Fill out the form below and I'll get back to you as soon as possible.", + "close": "Close", + "form": { + "email": "Email", + "emailPlaceholder": "your.email@example.com", + "name": "Name", + "namePlaceholder": "Your name", + "company": "Company", + "companyPlaceholder": "Company", + "subject": "Subject", + "subjectPlaceholder": "Subject", + "message": "Message", + "messagePlaceholder": "Your message...", + "submit": "Send Message", + "sending": "Sending...", + "note": "* Required fields" + }, + "success": { + "title": "Message Sent!", + "message": "Thank you for your message. I'll get back to you soon." + }, + "error": { + "title": "Error" + } + }, "widgets": { "backToTop": { "ariaLabel": "Back to top", @@ -196,9 +224,15 @@ "title": "Preparing PDF", "closeLabel": "Close notification" }, + "contact": { + "ariaLabel": "Contact me", + "tooltip": "Contact me" + }, "actionButtons": { "downloadPdf": "Download as PDF", - "printFriendly": "Print Friendly" + "printFriendly": "Print Friendly", + "plainText": "Plain Text", + "contact": "Contact" } } } diff --git a/data/ui-es.json b/data/ui-es.json index 2e1277d..3552e1c 100644 --- a/data/ui-es.json +++ b/data/ui-es.json @@ -159,6 +159,34 @@ "viewSource": "Ver proyecto en Github", "viewSourceSubtext": "¿Quieres saber cómo está hecho?" }, + "contactModal": { + "title": "Ponerse en contacto", + "subtitle": "¡Conectemos!", + "description": "¿Tienes alguna pregunta o estás interesado en trabajar juntos? Rellena el formulario a continuación y me pondré en contacto contigo lo antes posible.", + "close": "Cerrar", + "form": { + "email": "Correo electrónico", + "emailPlaceholder": "tu.email@ejemplo.com", + "name": "Nombre", + "namePlaceholder": "Tu nombre", + "company": "Empresa", + "companyPlaceholder": "Empresa", + "subject": "Asunto", + "subjectPlaceholder": "Asunto", + "message": "Mensaje", + "messagePlaceholder": "Tu mensaje...", + "submit": "Enviar mensaje", + "sending": "Enviando...", + "note": "* Campos obligatorios" + }, + "success": { + "title": "¡Mensaje enviado!", + "message": "Gracias por tu mensaje. Me pondré en contacto contigo pronto." + }, + "error": { + "title": "Error" + } + }, "widgets": { "backToTop": { "ariaLabel": "Volver arriba", @@ -196,9 +224,15 @@ "title": "Preparando PDF", "closeLabel": "Cerrar notificación" }, + "contact": { + "ariaLabel": "Contáctame", + "tooltip": "Contáctame" + }, "actionButtons": { "downloadPdf": "Descargar como PDF", - "printFriendly": "Imprimir amigable" + "printFriendly": "Imprimir amigable", + "plainText": "Texto Plano", + "contact": "Contacto" } } } diff --git a/go.mod b/go.mod index 77cd396..4cc6c72 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.25.1 require ( github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 github.com/chromedp/chromedp v0.14.2 + github.com/go-git/go-git/v5 v5.16.4 github.com/joho/godotenv v1.5.1 + github.com/k3a/html2text v1.2.1 ) require ( @@ -18,7 +20,6 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect - github.com/go-git/go-git/v5 v5.16.4 // indirect github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect diff --git a/go.sum b/go.sum index 2168fda..65d1184 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= @@ -16,13 +20,20 @@ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZ github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= @@ -35,39 +46,68 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY= +github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -78,12 +118,21 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/handlers/contact.go b/internal/handlers/contact.go new file mode 100644 index 0000000..ed46191 --- /dev/null +++ b/internal/handlers/contact.go @@ -0,0 +1,215 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/juanatsap/cv-site/internal/services" + "github.com/juanatsap/cv-site/internal/templates" +) + +// ContactHandler handles contact form submissions +type ContactHandler struct { + templates *templates.Manager + emailService *services.EmailService +} + +// NewContactHandler creates a new contact handler +func NewContactHandler(tmpl *templates.Manager, emailService *services.EmailService) *ContactHandler { + return &ContactHandler{ + templates: tmpl, + emailService: emailService, + } +} + +// ContactFormRequest represents the contact form submission +type ContactFormRequest struct { + Email string + Name string + Company string + Subject string + Message string + Honeypot string // Hidden field - should be empty + SubmitTime time.Time // Set by client, checked server-side + CSRFToken string +} + +// Submit handles POST /api/contact +func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) { + // Only accept POST + if r.Method != http.MethodPost { + HandleError(w, r, NewAppError(nil, "Method not allowed", http.StatusMethodNotAllowed, false)) + return + } + + // Parse form + if err := r.ParseForm(); err != nil { + log.Printf("ERROR parsing contact form: %v", err) + h.renderError(w, r, "Invalid form data. Please try again.") + return + } + + // Extract form data + req := &ContactFormRequest{ + Email: strings.TrimSpace(r.FormValue("email")), + Name: strings.TrimSpace(r.FormValue("name")), + Company: strings.TrimSpace(r.FormValue("company")), + Subject: strings.TrimSpace(r.FormValue("subject")), + Message: strings.TrimSpace(r.FormValue("message")), + Honeypot: r.FormValue("website"), // Honeypot field + CSRFToken: r.FormValue("csrf_token"), + } + + // Bot protection: Honeypot check + if req.Honeypot != "" { + log.Printf("SECURITY: Honeypot triggered from IP %s", getClientIP(r)) + // Don't reveal that we detected a bot - just show success + h.renderSuccess(w, r) + return + } + + // Bot protection: Timing check + submitTimeStr := r.FormValue("submit_time") + if submitTimeStr != "" { + // Parse submit time (Unix timestamp in milliseconds) + var submitTimeMs int64 + if _, err := fmt.Sscanf(submitTimeStr, "%d", &submitTimeMs); err == nil { + submitTime := time.Unix(0, submitTimeMs*int64(time.Millisecond)) + elapsed := time.Since(submitTime) + + // Reject if submitted too fast (< 2 seconds) + if elapsed < 2*time.Second { + log.Printf("SECURITY: Form submitted too fast (%v) from IP %s", elapsed, getClientIP(r)) + h.renderError(w, r, "Please take your time filling out the form.") + return + } + } + } + + // CSRF validation is handled by middleware + + // Validate required fields + if req.Email == "" { + h.renderError(w, r, "Email address is required.") + return + } + if req.Message == "" { + h.renderError(w, r, "Message is required.") + return + } + + // Create email data + emailData := &services.ContactFormData{ + Email: req.Email, + Name: req.Name, + Company: req.Company, + Subject: req.Subject, + Message: req.Message, + IP: getClientIP(r), + Time: time.Now(), + } + + // Send email + if err := h.emailService.SendContactForm(emailData); err != nil { + log.Printf("ERROR sending contact email: %v", err) + + // Check if it's a validation error or server error + if strings.Contains(err.Error(), "validation failed") { + h.renderError(w, r, err.Error()) + return + } + + // Internal server error + h.renderError(w, r, "Failed to send message. Please try again later.") + return + } + + // Log successful submission (without sensitive data) + log.Printf("Contact form submitted successfully from %s (%s)", req.Email, getClientIP(r)) + + // Render success response + h.renderSuccess(w, r) +} + +// renderSuccess renders the success partial +func (h *ContactHandler) renderSuccess(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + tmpl, err := h.templates.Render("contact_success.html") + if err != nil { + log.Printf("ERROR loading success template: %v", err) + // Fallback to simple HTML + _, _ = w.Write([]byte(`
+

Message Sent!

+

Thank you for your message. I'll get back to you soon.

+
`)) + return + } + + if err := tmpl.Execute(w, nil); err != nil { + log.Printf("ERROR rendering success template: %v", err) + _, _ = w.Write([]byte(`
+

Message Sent!

+

Thank you for your message. I'll get back to you soon.

+
`)) + } +} + +// renderError renders the error partial +func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, message string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + + data := map[string]interface{}{ + "Message": message, + } + + tmpl, err := h.templates.Render("contact_error.html") + if err != nil { + log.Printf("ERROR loading error template: %v", err) + // Fallback to simple HTML + _, _ = w.Write([]byte(`
+

Error

+

` + message + `

+
`)) + return + } + + if err := tmpl.Execute(w, data); err != nil { + log.Printf("ERROR rendering error template: %v", err) + _, _ = w.Write([]byte(`
+

Error

+

` + message + `

+
`)) + } +} + +// getClientIP extracts the client IP address from the request +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header (for proxies) + ip := r.Header.Get("X-Forwarded-For") + if ip != "" { + // X-Forwarded-For can contain multiple IPs, take the first one + ips := strings.Split(ip, ",") + return strings.TrimSpace(ips[0]) + } + + // Check X-Real-IP header + ip = r.Header.Get("X-Real-IP") + if ip != "" { + return ip + } + + // Fallback to RemoteAddr + ip = r.RemoteAddr + // Remove port if present + if colonIndex := strings.LastIndex(ip, ":"); colonIndex != -1 { + ip = ip[:colonIndex] + } + + return ip +} diff --git a/internal/handlers/cv_contact.go b/internal/handlers/cv_contact.go new file mode 100644 index 0000000..1e3c3d3 --- /dev/null +++ b/internal/handlers/cv_contact.go @@ -0,0 +1,253 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + uimodel "github.com/juanatsap/cv-site/internal/models/ui" +) + +// ============================================================================== +// CONTACT FORM SUBMISSION HANDLER +// Part of CVHandler - handles POST /api/contact +// ============================================================================== + +// ContactFormData represents the contact form submission +type ContactFormData struct { + Email string + Name string + Company string + Subject string + Message string + Website string // Honeypot field - should be empty + FormLoadedAt string // Timing field - Unix timestamp in milliseconds + Lang string +} + +// HandleContact handles contact form submissions +func (h *CVHandler) HandleContact(w http.ResponseWriter, r *http.Request) { + // Only accept POST requests + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse form data + if err := r.ParseForm(); err != nil { + log.Printf("Error parsing contact form: %v", err) + h.renderContactError(w, r, "Invalid form data. Please try again.") + return + } + + // Get language from query parameter + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = "en" + } + + // Validate language + if lang != "en" && lang != "es" { + lang = "en" + } + + // Extract form data + formData := &ContactFormData{ + Email: strings.TrimSpace(r.FormValue("email")), + Name: strings.TrimSpace(r.FormValue("name")), + Company: strings.TrimSpace(r.FormValue("company")), + Subject: strings.TrimSpace(r.FormValue("subject")), + Message: strings.TrimSpace(r.FormValue("message")), + Website: r.FormValue("website"), // Honeypot + FormLoadedAt: r.FormValue("form_loaded_at"), // Timing + Lang: lang, + } + + // Validate form data (includes bot protection) + if err := validateContactForm(formData, r); err != nil { + log.Printf("Contact form validation failed from IP %s: %v", getClientIP(r), err) + + // Don't reveal specific errors to potential bots + if strings.Contains(err.Error(), "spam detected") { + // Silently succeed for bots + h.renderContactSuccess(w, r, lang) + return + } + + h.renderContactError(w, r, err.Error()) + return + } + + // Log the contact form submission (in production, send email or save to database) + log.Printf("Contact form submission from %s (IP: %s)", formData.Email, getClientIP(r)) + log.Printf(" Name: %s, Company: %s", formData.Name, formData.Company) + log.Printf(" Subject: %s", formData.Subject) + log.Printf(" Message length: %d characters", len(formData.Message)) + + // TODO: Implement actual email sending or database storage here + // For now, we just log and return success + + // Render success response + h.renderContactSuccess(w, r, lang) +} + +// validateContactForm validates the contact form data and performs bot protection +func validateContactForm(data *ContactFormData, r *http.Request) error { + // Bot protection: Honeypot check - website field should be empty + if data.Website != "" { + return fmt.Errorf("spam detected: honeypot field filled") + } + + // Bot protection: Timing check - form should take at least 2 seconds to fill + if data.FormLoadedAt != "" { + loadedAt, err := strconv.ParseInt(data.FormLoadedAt, 10, 64) + if err == nil { + now := time.Now().UnixMilli() + elapsed := now - loadedAt + + // Form filled too quickly (< 2 seconds) - likely a bot + if elapsed < 2000 { + return fmt.Errorf("spam detected: form filled too quickly (%dms)", elapsed) + } + + // Form took too long (> 1 hour) - timestamp expired + if elapsed > 3600000 { + return fmt.Errorf("form session expired, please refresh and try again") + } + } + } + + // Required field validation + if data.Email == "" { + return fmt.Errorf("email address is required") + } + + if data.Message == "" { + return fmt.Errorf("message is required") + } + + // Email format validation (basic) + if !strings.Contains(data.Email, "@") || !strings.Contains(data.Email, ".") { + return fmt.Errorf("invalid email format") + } + + // Message length validation + if len(data.Message) < 10 { + return fmt.Errorf("message is too short (minimum 10 characters)") + } + + if len(data.Message) > 5000 { + return fmt.Errorf("message is too long (maximum 5000 characters)") + } + + return nil +} + +// renderContactSuccess renders the contact success partial +func (h *CVHandler) renderContactSuccess(w http.ResponseWriter, r *http.Request, lang string) { + // Load UI data for the specified language + ui, err := uimodel.LoadUI(lang) + if err != nil { + log.Printf("Error loading UI data: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Create template data + data := map[string]interface{}{ + "UI": ui, + "Lang": lang, + } + + // Render the success template + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + tmpl, err := h.templates.Render("contact-success") + if err != nil { + log.Printf("Error loading contact success template: %v", err) + // Fallback to simple HTML + _, _ = w.Write([]byte(`
+ +
+ Message Sent! +

Thank you for your message. I'll get back to you soon.

+
+
`)) + return + } + + if err := tmpl.Execute(w, data); err != nil { + log.Printf("Error rendering contact success template: %v", err) + _, _ = w.Write([]byte(`
+ +
+ Message Sent! +

Thank you for your message. I'll get back to you soon.

+
+
`)) + } +} + +// renderContactError renders the contact error partial +func (h *CVHandler) renderContactError(w http.ResponseWriter, r *http.Request, errorMessage string) { + // Get language from query parameter + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = "en" + } + + // Validate language + if lang != "en" && lang != "es" { + lang = "en" + } + + // Load UI data for the specified language + ui, err := uimodel.LoadUI(lang) + if err != nil { + log.Printf("Error loading UI data: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Create template data + data := map[string]interface{}{ + "UI": ui, + "Lang": lang, + "ErrorMessage": errorMessage, + } + + // Render the error template + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + + tmpl, err := h.templates.Render("contact-error") + if err != nil { + log.Printf("Error loading contact error template: %v", err) + // Fallback to simple HTML + _, _ = w.Write([]byte(`
+ +
+ Error +

` + errorMessage + `

+
+
`)) + return + } + + if err := tmpl.Execute(w, data); err != nil { + log.Printf("Error rendering contact error template: %v", err) + _, _ = w.Write([]byte(`
+ +
+ Error +

` + errorMessage + `

+
+
`)) + } +} + +// Note: getClientIP is defined in contact.go diff --git a/internal/handlers/cv_text.go b/internal/handlers/cv_text.go new file mode 100644 index 0000000..f919e6e --- /dev/null +++ b/internal/handlers/cv_text.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "bytes" + "log" + "net/http" + + "github.com/k3a/html2text" +) + +// ============================================================================== +// PLAIN TEXT HANDLER +// Converts HTML CV to readable plain text for terminal/AI consumption +// ============================================================================== + +// PlainText renders the CV as plain text +// Useful for: curl users, AI crawlers, accessibility, copy-paste +func (h *CVHandler) PlainText(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" { + http.Error(w, "Unsupported language. Use 'en' or 'es'", http.StatusBadRequest) + return + } + + // Prepare template data using shared helper + data, err := h.prepareTemplateData(lang) + if err != nil { + log.Printf("PlainText: Failed to load CV data: %v", err) + http.Error(w, "Failed to load CV data", http.StatusInternalServerError) + return + } + + // Add preferences for full CV display (show everything) + data["CVLengthClass"] = "cv-long" + data["ShowIcons"] = false // Icons don't render in text + data["ThemeClean"] = false + + // Render HTML template to buffer + tmpl, err := h.templates.Render("index.html") + if err != nil { + log.Printf("PlainText: Failed to load template: %v", err) + http.Error(w, "Failed to load template", http.StatusInternalServerError) + return + } + + var htmlBuffer bytes.Buffer + if err := tmpl.Execute(&htmlBuffer, data); err != nil { + log.Printf("PlainText: Failed to execute template: %v", err) + http.Error(w, "Failed to render template: "+err.Error(), http.StatusInternalServerError) + return + } + + // Convert HTML to plain text + text := html2text.HTML2Text(htmlBuffer.String()) + + // Set response headers + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + + // Write plain text response + _, _ = w.Write([]byte(text)) +} diff --git a/internal/middleware/browser_only.go b/internal/middleware/browser_only.go new file mode 100644 index 0000000..16a2bde --- /dev/null +++ b/internal/middleware/browser_only.go @@ -0,0 +1,120 @@ +package middleware + +import ( + "log" + "net/http" + "strings" +) + +const ( + // Custom header that browser JavaScript must set + browserHeaderName = "X-Requested-With" + browserHeaderValue = "XMLHttpRequest" +) + +// BrowserOnly restricts endpoint access to browser requests only +// Blocks curl, Postman, and other HTTP clients +// Requires: +// 1. User-Agent header (not curl/wget/etc) +// 2. Referer or Origin header from same domain +// 3. Custom header set by JavaScript (X-Requested-With: XMLHttpRequest) +func BrowserOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check 1: User-Agent validation + userAgent := r.Header.Get("User-Agent") + if userAgent == "" || isBotUserAgent(userAgent) { + log.Printf("SECURITY: Blocked non-browser User-Agent from IP %s: %s", getRequestIP(r), userAgent) + http.Error(w, "Forbidden: Browser access only", http.StatusForbidden) + return + } + + // Check 2: Require Referer or Origin header + referer := r.Header.Get("Referer") + origin := r.Header.Get("Origin") + + if referer == "" && origin == "" { + log.Printf("SECURITY: Blocked request without Referer/Origin from IP %s", getRequestIP(r)) + http.Error(w, "Forbidden: Browser access only", http.StatusForbidden) + return + } + + // Check 3: Custom header validation (set by JavaScript) + // For HTMX requests, check HX-Request header + // For fetch/XMLHttpRequest, check X-Requested-With header + hasHTMXHeader := r.Header.Get("HX-Request") == "true" + hasXMLHTTPHeader := r.Header.Get(browserHeaderName) == browserHeaderValue + hasCustomBrowserHeader := r.Header.Get("X-Browser-Request") == "true" + + if !hasHTMXHeader && !hasXMLHTTPHeader && !hasCustomBrowserHeader { + log.Printf("SECURITY: Blocked request without browser headers from IP %s", getRequestIP(r)) + http.Error(w, "Forbidden: Browser access only", http.StatusForbidden) + return + } + + // All checks passed + next.ServeHTTP(w, r) + }) +} + +// isBotUserAgent checks if the User-Agent is from a known HTTP client/bot +func isBotUserAgent(ua string) bool { + ua = strings.ToLower(ua) + + // Known HTTP clients and tools + blockedAgents := []string{ + "curl", + "wget", + "postman", + "insomnia", + "httpie", + "python-requests", + "python-urllib", + "java", + "okhttp", + "go-http-client", + "axios", // Node.js axios without proper browser headers + "node-fetch", + "apache-httpclient", + "libwww-perl", + "php", + "ruby", + "scrapy", + "bot", + "crawler", + "spider", + } + + for _, blocked := range blockedAgents { + if strings.Contains(ua, blocked) { + return true + } + } + + return false +} + +// getRequestIP extracts the client IP from the request +func getRequestIP(r *http.Request) string { + // Try X-Forwarded-For first (for proxies/load balancers) + ip := r.Header.Get("X-Forwarded-For") + if ip != "" { + // Take first IP if multiple + ips := strings.Split(ip, ",") + return strings.TrimSpace(ips[0]) + } + + // Try X-Real-IP + ip = r.Header.Get("X-Real-IP") + if ip != "" { + return ip + } + + // Fallback to RemoteAddr + ip = r.RemoteAddr + // Remove port + if idx := strings.LastIndex(ip, ":"); idx != -1 { + ip = ip[:idx] + } + + return ip +} diff --git a/internal/middleware/contact_rate_limit.go b/internal/middleware/contact_rate_limit.go new file mode 100644 index 0000000..c587333 --- /dev/null +++ b/internal/middleware/contact_rate_limit.go @@ -0,0 +1,131 @@ +package middleware + +import ( + "net/http" + "strings" + "sync" + "time" +) + +// contactRateLimitEntry tracks rate limiting for contact form per IP +type contactRateLimitEntry struct { + count int + resetTime time.Time +} + +// ContactRateLimiter provides rate limiting specifically for contact form +// Allows 5 submissions per hour per IP address +type ContactRateLimiter struct { + mu sync.RWMutex + clients map[string]*contactRateLimitEntry +} + +// NewContactRateLimiter creates a new contact form rate limiter +// Default: 5 requests per hour per IP +func NewContactRateLimiter() *ContactRateLimiter { + rl := &ContactRateLimiter{ + clients: make(map[string]*contactRateLimitEntry), + } + + // Cleanup expired entries every 10 minutes + go rl.cleanup() + + return rl +} + +// Middleware returns rate limiting middleware for contact form +func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get client IP (handle X-Forwarded-For for proxies) + ip := r.Header.Get("X-Forwarded-For") + if ip == "" { + ip = r.Header.Get("X-Real-IP") + } + if ip == "" { + ip = strings.Split(r.RemoteAddr, ":")[0] + } + + // Extract first IP if multiple IPs in X-Forwarded-For + if strings.Contains(ip, ",") { + ip = strings.TrimSpace(strings.Split(ip, ",")[0]) + } + + if !rl.allow(ip) { + // Check if HTMX request + isHTMX := r.Header.Get("HX-Request") != "" + + if isHTMX { + // Return HTMX-friendly error + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`
+

Too Many Requests

+

You've submitted too many contact forms. Please wait an hour before trying again.

+
`)) + } else { + w.Header().Set("Retry-After", "3600") // 1 hour + http.Error(w, "Too many contact form submissions. Please try again in an hour.", http.StatusTooManyRequests) + } + return + } + + next.ServeHTTP(w, r) + }) +} + +// allow checks if the request is allowed based on rate limit +// Limit: 5 submissions per hour +func (rl *ContactRateLimiter) allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + limit := 5 + window := 1 * time.Hour + + entry, exists := rl.clients[ip] + if !exists || now.After(entry.resetTime) { + // New client or window expired + rl.clients[ip] = &contactRateLimitEntry{ + count: 1, + resetTime: now.Add(window), + } + return true + } + + if entry.count >= limit { + return false + } + + entry.count++ + return true +} + +// cleanup removes expired entries periodically +func (rl *ContactRateLimiter) cleanup() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + rl.mu.Lock() + now := time.Now() + for ip, entry := range rl.clients { + if now.After(entry.resetTime) { + delete(rl.clients, ip) + } + } + rl.mu.Unlock() + } +} + +// GetStats returns current rate limit statistics (for monitoring/debugging) +func (rl *ContactRateLimiter) GetStats() map[string]interface{} { + rl.mu.RLock() + defer rl.mu.RUnlock() + + return map[string]interface{}{ + "total_clients": len(rl.clients), + "limit": 5, + "window": "1 hour", + } +} diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go new file mode 100644 index 0000000..7bb1f3b --- /dev/null +++ b/internal/middleware/csrf.go @@ -0,0 +1,200 @@ +package middleware + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "log" + "net/http" + "sync" + "time" +) + +const ( + csrfTokenLength = 32 + csrfCookieName = "csrf_token" + csrfFormField = "csrf_token" + csrfTokenTTL = 24 * time.Hour +) + +// csrfTokenEntry stores token with expiration +type csrfTokenEntry struct { + token string + expiresAt time.Time +} + +// CSRFProtection provides CSRF token generation and validation +type CSRFProtection struct { + mu sync.RWMutex + tokens map[string]*csrfTokenEntry // map[token]entry +} + +// NewCSRFProtection creates a new CSRF protection instance +func NewCSRFProtection() *CSRFProtection { + csrf := &CSRFProtection{ + tokens: make(map[string]*csrfTokenEntry), + } + + // Cleanup expired tokens every hour + go csrf.cleanup() + + return csrf +} + +// Middleware provides CSRF protection for state-changing operations +// GET requests: Generate and set CSRF token +// POST/PUT/DELETE: Validate CSRF token +func (c *CSRFProtection) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only validate on state-changing methods + if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete { + if !c.validateToken(r) { + log.Printf("SECURITY: CSRF validation failed from IP %s", getClientIP(r)) + + // Check if HTMX request + isHTMX := r.Header.Get("HX-Request") != "" + + if isHTMX { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`
+

Security Error

+

Invalid security token. Please refresh the page and try again.

+
`)) + } else { + http.Error(w, "CSRF validation failed", http.StatusForbidden) + } + return + } + } + + next.ServeHTTP(w, r) + }) +} + +// generateToken creates a new CSRF token +func (c *CSRFProtection) generateToken() (string, error) { + bytes := make([]byte, csrfTokenLength) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + token := base64.URLEncoding.EncodeToString(bytes) + + // Store token with expiration + c.mu.Lock() + c.tokens[token] = &csrfTokenEntry{ + token: token, + expiresAt: time.Now().Add(csrfTokenTTL), + } + c.mu.Unlock() + + return token, nil +} + +// GetToken retrieves or generates a CSRF token for the request +// This should be called when rendering forms +func (c *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (string, error) { + // Check if token exists in cookie + cookie, err := r.Cookie(csrfCookieName) + if err == nil && cookie.Value != "" { + // Validate existing token + c.mu.RLock() + entry, exists := c.tokens[cookie.Value] + c.mu.RUnlock() + + if exists && time.Now().Before(entry.expiresAt) { + // Token is valid, return it + return cookie.Value, nil + } + } + + // Generate new token + token, err := c.generateToken() + if err != nil { + return "", fmt.Errorf("failed to generate CSRF token: %w", err) + } + + // Set cookie + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, // Only set Secure flag if using HTTPS + SameSite: http.SameSiteStrictMode, + MaxAge: int(csrfTokenTTL.Seconds()), + }) + + return token, nil +} + +// validateToken validates the CSRF token from the request +func (c *CSRFProtection) validateToken(r *http.Request) bool { + // Get token from form + var formToken string + + // Try form value first + if err := r.ParseForm(); err == nil { + formToken = r.FormValue(csrfFormField) + } + + // If not in form, try header (for AJAX requests) + if formToken == "" { + formToken = r.Header.Get("X-CSRF-Token") + } + + if formToken == "" { + log.Printf("CSRF: No token in request") + return false + } + + // Get token from cookie + cookie, err := r.Cookie(csrfCookieName) + if err != nil || cookie.Value == "" { + log.Printf("CSRF: No token in cookie") + return false + } + + // Tokens must match + if formToken != cookie.Value { + log.Printf("CSRF: Token mismatch") + return false + } + + // Validate token exists and is not expired + c.mu.RLock() + entry, exists := c.tokens[formToken] + c.mu.RUnlock() + + if !exists { + log.Printf("CSRF: Token not found in store") + return false + } + + if time.Now().After(entry.expiresAt) { + log.Printf("CSRF: Token expired") + return false + } + + return true +} + +// cleanup removes expired tokens periodically +func (c *CSRFProtection) cleanup() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + c.mu.Lock() + now := time.Now() + for token, entry := range c.tokens { + if now.After(entry.expiresAt) { + delete(c.tokens, token) + } + } + c.mu.Unlock() + } +} + +// Note: getClientIP is defined in security_logger.go diff --git a/internal/middleware/security_logger.go b/internal/middleware/security_logger.go new file mode 100644 index 0000000..ccab2fd --- /dev/null +++ b/internal/middleware/security_logger.go @@ -0,0 +1,228 @@ +package middleware + +import ( + "encoding/json" + "log" + "net/http" + "os" + "strings" + "time" +) + +// SecurityEvent represents a security-related event +type SecurityEvent struct { + Timestamp time.Time `json:"timestamp"` + EventType string `json:"event_type"` + Severity string `json:"severity"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` + Details string `json:"details"` +} + +// EventSeverity levels +const ( + SeverityCritical = "CRITICAL" + SeverityHigh = "HIGH" + SeverityMedium = "MEDIUM" + SeverityLow = "LOW" + SeverityInfo = "INFO" +) + +// Event types +const ( + EventBlocked = "BLOCKED" + EventCSRFViolation = "CSRF_VIOLATION" + EventOriginViolation = "ORIGIN_VIOLATION" + EventRateLimitExceeded = "RATE_LIMIT_EXCEEDED" + EventValidationFailed = "VALIDATION_FAILED" + EventSuspiciousUserAgent = "SUSPICIOUS_USER_AGENT" + EventContactFormSent = "CONTACT_FORM_SENT" + EventContactFormFailed = "CONTACT_FORM_FAILED" + EventPDFGenerated = "PDF_GENERATED" + EventPDFGenerationFailed = "PDF_GENERATION_FAILED" + EventEmailSendFailed = "EMAIL_SEND_FAILED" + EventBotDetected = "BOT_DETECTED" +) + +// LogSecurityEvent logs a security event in structured JSON format +func LogSecurityEvent(eventType string, r *http.Request, details string) { + severity := getSeverity(eventType) + + event := SecurityEvent{ + Timestamp: time.Now(), + EventType: eventType, + Severity: severity, + IP: getClientIP(r), + UserAgent: r.Header.Get("User-Agent"), + Method: r.Method, + Path: r.URL.Path, + Details: details, + } + + // JSON format for easy parsing by SIEM systems + eventJSON, err := json.Marshal(event) + if err != nil { + log.Printf("ERROR: Failed to marshal security event: %v", err) + return + } + + // Log to stdout (captured by systemd/Docker) + log.Printf("[SECURITY] %s", eventJSON) + + // Also log to separate security log file in production + if os.Getenv("GO_ENV") == "production" { + logToSecurityFile(eventJSON) + } +} + +// getSeverity determines the severity level based on event type +func getSeverity(eventType string) string { + switch eventType { + case EventBlocked, EventCSRFViolation, EventOriginViolation: + return SeverityHigh + case EventRateLimitExceeded, EventValidationFailed, EventSuspiciousUserAgent, + EventContactFormFailed, EventPDFGenerationFailed, EventEmailSendFailed: + return SeverityMedium + case EventBotDetected: + return SeverityLow + case EventContactFormSent, EventPDFGenerated: + return SeverityInfo + default: + return SeverityLow + } +} + +// getClientIP extracts the real client IP from request headers +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header (proxy/load balancer) + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // Take first IP from comma-separated list + ips := strings.Split(xff, ",") + return strings.TrimSpace(ips[0]) + } + + // Check X-Real-IP header + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + + // Fallback to RemoteAddr (remove port) + ip := r.RemoteAddr + if idx := strings.LastIndex(ip, ":"); idx != -1 { + ip = ip[:idx] + } + return ip +} + +// logToSecurityFile appends security events to a dedicated log file +func logToSecurityFile(eventJSON []byte) { + // Create log directory if it doesn't exist + logDir := "/var/log/cv-app" + if err := os.MkdirAll(logDir, 0755); err != nil { + log.Printf("WARNING: Failed to create log directory: %v", err) + return + } + + // Open security log file (append mode) + logPath := logDir + "/security.log" + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Printf("WARNING: Failed to open security log file: %v", err) + return + } + defer f.Close() + + // Write event with newline + if _, err := f.Write(eventJSON); err != nil { + log.Printf("WARNING: Failed to write to security log: %v", err) + return + } + if _, err := f.WriteString("\n"); err != nil { + log.Printf("WARNING: Failed to write newline to security log: %v", err) + } +} + +// SecurityLogger middleware logs all requests with security context +func SecurityLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Wrap response writer to capture status + wrapped := &responseWriter{ + ResponseWriter: w, + status: http.StatusOK, + } + + // Process request + next.ServeHTTP(wrapped, r) + + // Log security-relevant requests + duration := time.Since(start) + + // Log high-value endpoints + if isSecurityRelevantPath(r.URL.Path) { + details := map[string]interface{}{ + "status": wrapped.status, + "duration_ms": duration.Milliseconds(), + "bytes": wrapped.written, + } + detailsJSON, _ := json.Marshal(details) + + event := SecurityEvent{ + Timestamp: time.Now(), + EventType: "REQUEST", + Severity: SeverityInfo, + IP: getClientIP(r), + UserAgent: r.Header.Get("User-Agent"), + Method: r.Method, + Path: r.URL.Path, + Details: string(detailsJSON), + } + + eventJSON, _ := json.Marshal(event) + log.Printf("[SECURITY] %s", eventJSON) + } + + // Log errors and security failures + if wrapped.status >= 400 { + severity := SeverityLow + if wrapped.status == 403 || wrapped.status == 429 { + severity = SeverityMedium + } + + event := SecurityEvent{ + Timestamp: time.Now(), + EventType: "HTTP_ERROR", + Severity: severity, + IP: getClientIP(r), + UserAgent: r.Header.Get("User-Agent"), + Method: r.Method, + Path: r.URL.Path, + Details: http.StatusText(wrapped.status), + } + + eventJSON, _ := json.Marshal(event) + log.Printf("[SECURITY] %s", eventJSON) + } + }) +} + +// isSecurityRelevantPath determines if a path should be logged for security +func isSecurityRelevantPath(path string) bool { + securityPaths := []string{ + "/api/contact", + "/export/pdf", + "/toggle/", + "/switch-language", + } + + for _, sp := range securityPaths { + if strings.HasPrefix(path, sp) { + return true + } + } + + return false +} diff --git a/internal/models/ui/ui.go b/internal/models/ui/ui.go index 7226bf0..1ca9325 100644 --- a/internal/models/ui/ui.go +++ b/internal/models/ui/ui.go @@ -12,6 +12,7 @@ type UI struct { PdfModal PdfModal `json:"pdfModal"` ShortcutsModal ShortcutsModal `json:"shortcutsModal"` InfoModal InfoModal `json:"infoModal"` + ContactModal ContactModal `json:"contactModal"` Widgets Widgets `json:"widgets"` } @@ -142,6 +143,38 @@ type TechStack struct { CSS3 string `json:"css3"` } +// ContactModal labels +type ContactModal struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Description string `json:"description"` + Close string `json:"close"` + Form ContactFormLabel `json:"form"` + Success ContactResult `json:"success"` + Error ContactResult `json:"error"` +} + +type ContactFormLabel struct { + Email string `json:"email"` + EmailPlaceholder string `json:"emailPlaceholder"` + Name string `json:"name"` + NamePlaceholder string `json:"namePlaceholder"` + Company string `json:"company"` + CompanyPlaceholder string `json:"companyPlaceholder"` + Subject string `json:"subject"` + SubjectPlaceholder string `json:"subjectPlaceholder"` + Message string `json:"message"` + MessagePlaceholder string `json:"messagePlaceholder"` + Submit string `json:"submit"` + Sending string `json:"sending"` + Note string `json:"note"` +} + +type ContactResult struct { + Title string `json:"title"` + Message string `json:"message,omitempty"` +} + // Widget label types type Widgets struct { BackToTop WidgetLabel `json:"backToTop"` @@ -152,6 +185,7 @@ type Widgets struct { ZoomToggle WidgetLabel `json:"zoomToggle"` ZoomControl ZoomControlLabel `json:"zoomControl"` PdfToast PdfToastLabel `json:"pdfToast"` + Contact WidgetLabel `json:"contact"` ActionButtons ActionButtonsLabel `json:"actionButtons"` } @@ -177,4 +211,6 @@ type PdfToastLabel struct { type ActionButtonsLabel struct { DownloadPdf string `json:"downloadPdf"` PrintFriendly string `json:"printFriendly"` + PlainText string `json:"plainText"` + Contact string `json:"contact"` } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 6d9cf01..472d5c3 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -9,7 +9,7 @@ import ( ) // Setup configures all application routes and middleware -func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler { +func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler, contactHandler *handlers.ContactHandler) http.Handler { mux := http.NewServeMux() // Shortcut routes for default CV (year-aware) - MUST be before "/" route @@ -19,6 +19,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) // Public routes mux.HandleFunc("/", cvHandler.Home) mux.HandleFunc("/cv", cvHandler.CVContent) + mux.HandleFunc("/text", cvHandler.PlainText) // Plain text version for curl/AI mux.HandleFunc("/health", healthHandler.Check) // HTMX endpoints for interactive controls @@ -27,6 +28,13 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) mux.HandleFunc("/toggle/icons", cvHandler.ToggleIcons) mux.HandleFunc("/toggle/theme", cvHandler.ToggleTheme) + // Contact form endpoint (simple rate limiting) + contactRateLimiter := middleware.NewRateLimiter(5, 1*time.Hour) + protectedContactHandler := contactRateLimiter.Middleware( + http.HandlerFunc(contactHandler.Submit), + ) + mux.Handle("/api/contact", protectedContactHandler) + // Protected PDF endpoint with rate limiting (3 requests/minute per IP) pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute) protectedPDFHandler := middleware.OriginChecker( diff --git a/internal/services/email.go b/internal/services/email.go new file mode 100644 index 0000000..2fbd9ae --- /dev/null +++ b/internal/services/email.go @@ -0,0 +1,274 @@ +package services + +import ( + "bytes" + "crypto/tls" + "fmt" + "html/template" + "log" + "net/smtp" + "strings" + "time" +) + +// EmailConfig holds SMTP configuration +type EmailConfig struct { + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPassword string + FromEmail string + ToEmail string +} + +// EmailService handles email sending operations +type EmailService struct { + config *EmailConfig +} + +// NewEmailService creates a new email service +func NewEmailService(config *EmailConfig) *EmailService { + return &EmailService{ + config: config, + } +} + +// ContactFormData represents contact form submission data +type ContactFormData struct { + Email string + Name string + Company string + Subject string + Message string + IP string + Time time.Time +} + +// Validate performs validation on contact form data +func (c *ContactFormData) Validate() error { + // Sanitize inputs + c.Email = strings.TrimSpace(c.Email) + c.Name = strings.TrimSpace(c.Name) + c.Company = strings.TrimSpace(c.Company) + c.Subject = strings.TrimSpace(c.Subject) + c.Message = strings.TrimSpace(c.Message) + + // Required fields + if c.Email == "" { + return fmt.Errorf("email is required") + } + if c.Message == "" { + return fmt.Errorf("message is required") + } + + // Email format validation (basic) + if !strings.Contains(c.Email, "@") || !strings.Contains(c.Email, ".") { + return fmt.Errorf("invalid email format") + } + + // Prevent email header injection + if containsNewlines(c.Email) { + return fmt.Errorf("invalid email: contains prohibited characters") + } + if containsNewlines(c.Subject) { + return fmt.Errorf("invalid subject: contains prohibited characters") + } + + // Length validation + if len(c.Email) > 254 { + return fmt.Errorf("email too long (max 254 characters)") + } + if len(c.Name) > 100 { + return fmt.Errorf("name too long (max 100 characters)") + } + if len(c.Company) > 100 { + return fmt.Errorf("company too long (max 100 characters)") + } + if len(c.Subject) > 200 { + return fmt.Errorf("subject too long (max 200 characters)") + } + if len(c.Message) > 5000 { + return fmt.Errorf("message too long (max 5000 characters)") + } + if len(c.Message) < 10 { + return fmt.Errorf("message too short (min 10 characters)") + } + + return nil +} + +// containsNewlines checks for newline characters that could enable header injection +func containsNewlines(s string) bool { + return strings.ContainsAny(s, "\r\n") +} + +// SendContactForm sends a contact form email +func (e *EmailService) SendContactForm(data *ContactFormData) error { + // Validate data + if err := data.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Prepare email content + subject := "[CV Contact] " + if data.Subject != "" { + subject += data.Subject + } else { + subject += "New Message" + } + + // Build email body + body, err := e.buildEmailBody(data) + if err != nil { + return fmt.Errorf("failed to build email body: %w", err) + } + + // Send email + if err := e.sendEmail(subject, body); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + // Log successful send (without sensitive data) + log.Printf("Contact form email sent successfully to %s from %s", e.config.ToEmail, data.Email) + + return nil +} + +// buildEmailBody creates the email body from template +func (e *EmailService) buildEmailBody(data *ContactFormData) (string, error) { + emailTemplate := `New contact form submission: + +From: {{.Email}} +Name: {{if .Name}}{{.Name}}{{else}}Not provided{{end}} +Company: {{if .Company}}{{.Company}}{{else}}Not provided{{end}} +Subject: {{if .Subject}}{{.Subject}}{{else}}Not provided{{end}} + +Message: +{{.Message}} + +--- +IP: {{.IP}} +Time: {{.Time.Format "2006-01-02 15:04:05 MST"}} +` + + tmpl, err := template.New("contact").Parse(emailTemplate) + if err != nil { + return "", err + } + + var body bytes.Buffer + if err := tmpl.Execute(&body, data); err != nil { + return "", err + } + + return body.String(), nil +} + +// sendEmail sends an email using SMTP +func (e *EmailService) sendEmail(subject, body string) error { + // Validate config + if e.config.SMTPHost == "" || e.config.SMTPPort == "" { + return fmt.Errorf("SMTP configuration incomplete") + } + if e.config.SMTPUser == "" || e.config.SMTPPassword == "" { + return fmt.Errorf("SMTP credentials missing") + } + if e.config.ToEmail == "" { + return fmt.Errorf("recipient email not configured") + } + + // Build email message + from := e.config.FromEmail + if from == "" { + from = e.config.SMTPUser + } + + to := e.config.ToEmail + message := e.formatMessage(from, to, subject, body) + + // SMTP server address + addr := fmt.Sprintf("%s:%s", e.config.SMTPHost, e.config.SMTPPort) + + // Setup authentication + auth := smtp.PlainAuth("", e.config.SMTPUser, e.config.SMTPPassword, e.config.SMTPHost) + + // Connect to SMTP server with TLS + client, err := e.connectSMTP(addr) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer client.Close() + + // Authenticate + if err = client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + + // Set sender and recipient + if err = client.Mail(from); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("failed to set recipient: %w", err) + } + + // Send message + w, err := client.Data() + if err != nil { + return fmt.Errorf("failed to get data writer: %w", err) + } + + _, err = w.Write([]byte(message)) + if err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + + err = w.Close() + if err != nil { + return fmt.Errorf("failed to close writer: %w", err) + } + + return client.Quit() +} + +// connectSMTP establishes an SMTP connection with TLS +func (e *EmailService) connectSMTP(addr string) (*smtp.Client, error) { + // Connect to server + client, err := smtp.Dial(addr) + if err != nil { + return nil, err + } + + // Start TLS + tlsConfig := &tls.Config{ + ServerName: e.config.SMTPHost, + MinVersion: tls.VersionTLS12, + } + + if err = client.StartTLS(tlsConfig); err != nil { + client.Close() + return nil, err + } + + return client, nil +} + +// formatMessage formats an email message with proper headers +func (e *EmailService) formatMessage(from, to, subject, body string) string { + headers := make(map[string]string) + headers["From"] = from + headers["To"] = to + headers["Subject"] = subject + headers["MIME-Version"] = "1.0" + headers["Content-Type"] = "text/plain; charset=\"utf-8\"" + headers["Date"] = time.Now().Format(time.RFC1123Z) + + var message strings.Builder + for k, v := range headers { + message.WriteString(fmt.Sprintf("%s: %s\r\n", k, v)) + } + message.WriteString("\r\n") + message.WriteString(body) + + return message.String() +} diff --git a/internal/validation/contact.go b/internal/validation/contact.go new file mode 100644 index 0000000..9f18e97 --- /dev/null +++ b/internal/validation/contact.go @@ -0,0 +1,352 @@ +package validation + +import ( + "errors" + "html" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +// ContactFormRequest represents a validated contact form submission +type ContactFormRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Company string `json:"company"` + Subject string `json:"subject"` + Message string `json:"message"` + Honeypot string `json:"website"` // Should always be empty (bot trap) + Timestamp int64 `json:"timestamp"` // Form load time (set by server) +} + +// ValidationError represents a validation error with field context +type ValidationError struct { + Field string + Message string +} + +// Error implements the error interface +func (e *ValidationError) Error() string { + return e.Field + ": " + e.Message +} + +// ValidateContactForm performs comprehensive validation on contact form data +func ValidateContactForm(req *ContactFormRequest) error { + // 1. Honeypot check (bot detection) + if req.Honeypot != "" { + return &ValidationError{ + Field: "website", + Message: "Bot detected", + } + } + + // 2. Timing check (form must be displayed for at least 2 seconds) + if req.Timestamp > 0 { + now := time.Now().Unix() + timeTaken := now - req.Timestamp + if timeTaken < 2 { + return &ValidationError{ + Field: "timestamp", + Message: "Form submitted too quickly (bot detected)", + } + } + // Also reject if timestamp is in the future or too old (> 24 hours) + if timeTaken < 0 || timeTaken > 86400 { + return &ValidationError{ + Field: "timestamp", + Message: "Invalid timestamp", + } + } + } + + // 3. Required fields + if strings.TrimSpace(req.Name) == "" { + return &ValidationError{ + Field: "name", + Message: "Name is required", + } + } + + if strings.TrimSpace(req.Email) == "" { + return &ValidationError{ + Field: "email", + Message: "Email is required", + } + } + + if strings.TrimSpace(req.Subject) == "" { + return &ValidationError{ + Field: "subject", + Message: "Subject is required", + } + } + + if strings.TrimSpace(req.Message) == "" { + return &ValidationError{ + Field: "message", + Message: "Message is required", + } + } + + // 4. Length validation + if utf8.RuneCountInString(req.Name) > 100 { + return &ValidationError{ + Field: "name", + Message: "Name must be 100 characters or less", + } + } + + if utf8.RuneCountInString(req.Email) > 254 { + return &ValidationError{ + Field: "email", + Message: "Email must be 254 characters or less", + } + } + + if utf8.RuneCountInString(req.Company) > 100 { + return &ValidationError{ + Field: "company", + Message: "Company must be 100 characters or less", + } + } + + if utf8.RuneCountInString(req.Subject) > 200 { + return &ValidationError{ + Field: "subject", + Message: "Subject must be 200 characters or less", + } + } + + if utf8.RuneCountInString(req.Message) > 5000 { + return &ValidationError{ + Field: "message", + Message: "Message must be 5000 characters or less", + } + } + + // 5. Email validation (RFC 5322) + if !IsValidEmail(req.Email) { + return &ValidationError{ + Field: "email", + Message: "Invalid email address format", + } + } + + // 6. Email header injection prevention + if ContainsEmailInjection(req.Name) { + return &ValidationError{ + Field: "name", + Message: "Name contains invalid characters", + } + } + + if ContainsEmailInjection(req.Email) { + return &ValidationError{ + Field: "email", + Message: "Email contains invalid characters", + } + } + + if ContainsEmailInjection(req.Subject) { + return &ValidationError{ + Field: "subject", + Message: "Subject contains invalid characters", + } + } + + // 7. Name validation (letters, spaces, hyphens, apostrophes only) + if !IsValidName(req.Name) { + return &ValidationError{ + Field: "name", + Message: "Name can only contain letters, spaces, hyphens, and apostrophes", + } + } + + // 8. Subject validation (alphanumeric + safe punctuation) + if !IsValidSubject(req.Subject) { + return &ValidationError{ + Field: "subject", + Message: "Subject contains invalid characters", + } + } + + // 9. Company validation (optional, but if provided must be alphanumeric) + if req.Company != "" && !IsValidCompany(req.Company) { + return &ValidationError{ + Field: "company", + Message: "Company name contains invalid characters", + } + } + + return nil +} + +// IsValidEmail validates email format per RFC 5322 (simplified) +func IsValidEmail(email string) bool { + email = strings.TrimSpace(email) + + // Basic length check + if len(email) < 3 || len(email) > 254 { + return false + } + + // Must contain @ + parts := strings.Split(email, "@") + if len(parts) != 2 { + return false + } + + local := parts[0] + domain := parts[1] + + // Local part validation + if len(local) == 0 || len(local) > 64 { + return false + } + + // Domain must have at least one dot (TLD required) + if !strings.Contains(domain, ".") { + return false + } + + // RFC 5322 simplified regex + // This is a reasonable approximation - full RFC 5322 is extremely complex + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$`) + + return emailRegex.MatchString(email) +} + +// ContainsEmailInjection checks for email header injection attempts +// Email header injection: attacker tries to inject additional headers via newlines +func ContainsEmailInjection(s string) bool { + // Check for newlines (CRLF or LF) + if strings.ContainsAny(s, "\r\n") { + return true + } + + // Check for email header patterns (case-insensitive) + sLower := strings.ToLower(s) + + dangerousPatterns := []string{ + "content-type:", + "mime-version:", + "content-transfer-encoding:", + "bcc:", + "cc:", + "to:", + "from:", + "subject:", + "reply-to:", + "x-mailer:", + } + + for _, pattern := range dangerousPatterns { + if strings.Contains(sLower, pattern) { + return true + } + } + + return false +} + +// IsValidName validates name format +// Allows: letters (any language), spaces, hyphens, apostrophes +func IsValidName(name string) bool { + name = strings.TrimSpace(name) + + if name == "" { + return false + } + + // Allow unicode letters, spaces, hyphens, apostrophes + // This supports international names (Juan José, François, 田中, etc.) + nameRegex := regexp.MustCompile(`^[\p{L}\s'-]+$`) + + return nameRegex.MatchString(name) +} + +// IsValidSubject validates subject format +// Allows: alphanumeric, spaces, and common punctuation (including #) +func IsValidSubject(subject string) bool { + subject = strings.TrimSpace(subject) + + if subject == "" { + return false + } + + // Allow alphanumeric (any language), spaces, and safe punctuation (including #) + subjectRegex := regexp.MustCompile(`^[\p{L}\p{N}\s.,!?'"()\-:;#]+$`) + + return subjectRegex.MatchString(subject) +} + +// IsValidCompany validates company name format +// Allows: alphanumeric, spaces, and common business punctuation +func IsValidCompany(company string) bool { + company = strings.TrimSpace(company) + + if company == "" { + return true // Optional field + } + + // Allow alphanumeric (any language), spaces, and business punctuation + companyRegex := regexp.MustCompile(`^[\p{L}\p{N}\s.,&'()\-]+$`) + + return companyRegex.MatchString(company) +} + +// SanitizeContactForm sanitizes contact form data +// This should be called AFTER validation +func SanitizeContactForm(req *ContactFormRequest) { + // 1. Trim whitespace + req.Name = strings.TrimSpace(req.Name) + req.Email = strings.TrimSpace(req.Email) + req.Company = strings.TrimSpace(req.Company) + req.Subject = strings.TrimSpace(req.Subject) + req.Message = strings.TrimSpace(req.Message) + + // 2. Remove any newlines from header fields (belt-and-suspenders) + req.Name = removeNewlines(req.Name) + req.Email = removeNewlines(req.Email) + req.Company = removeNewlines(req.Company) + req.Subject = removeNewlines(req.Subject) + + // 3. HTML escape message body (prevent XSS in email clients) + req.Message = html.EscapeString(req.Message) + + // 4. Normalize whitespace in message (collapse multiple spaces/newlines) + req.Message = normalizeWhitespace(req.Message) +} + +// removeNewlines removes all newline characters +func removeNewlines(s string) string { + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, "\n", "") + return s +} + +// normalizeWhitespace collapses multiple spaces/newlines to single instances +func normalizeWhitespace(s string) string { + // Replace multiple newlines with double newline (paragraph break) + newlineRegex := regexp.MustCompile(`\n{3,}`) + s = newlineRegex.ReplaceAllString(s, "\n\n") + + // Replace multiple spaces (but not newlines) with single space + spaceRegex := regexp.MustCompile(`[^\S\n]+`) + s = spaceRegex.ReplaceAllString(s, " ") + + return strings.TrimSpace(s) +} + +// Common validation errors +var ( + ErrBotDetected = errors.New("bot detected") + ErrInvalidEmail = errors.New("invalid email format") + ErrEmailInjection = errors.New("email injection attempt detected") + ErrInvalidName = errors.New("invalid name format") + ErrInvalidSubject = errors.New("invalid subject format") + ErrRequiredField = errors.New("required field missing") + ErrFieldTooLong = errors.New("field exceeds maximum length") + ErrSubmittedTooFast = errors.New("form submitted too quickly") +) diff --git a/internal/validation/contact_test.go b/internal/validation/contact_test.go new file mode 100644 index 0000000..0efc26a --- /dev/null +++ b/internal/validation/contact_test.go @@ -0,0 +1,524 @@ +package validation + +import ( + "strings" + "testing" + "time" +) + +// ============================================================================== +// EMAIL VALIDATION TESTS +// ============================================================================== + +func TestIsValidEmail(t *testing.T) { + tests := []struct { + name string + email string + want bool + }{ + // Valid emails + {"Valid standard", "test@example.com", true}, + {"Valid with subdomain", "user@mail.example.com", true}, + {"Valid with plus", "user+tag@example.com", true}, + {"Valid with dot", "first.last@example.com", true}, + {"Valid with hyphen", "user-name@example.com", true}, + {"Valid with numbers", "user123@example.com", true}, + + // Invalid emails + {"Empty", "", false}, + {"No @", "userexample.com", false}, + {"Multiple @", "user@@example.com", false}, + {"No domain", "user@", false}, + {"No local part", "@example.com", false}, + {"Spaces", "user @example.com", false}, + {"Missing TLD", "user@example", false}, + {"Too long", strings.Repeat("a", 250) + "@example.com", false}, + {"Special chars", "user<>@example.com", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidEmail(tt.email); got != tt.want { + t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want) + } + }) + } +} + +// ============================================================================== +// EMAIL INJECTION TESTS +// ============================================================================== + +func TestContainsEmailInjection(t *testing.T) { + tests := []struct { + name string + input string + want bool // true if injection detected + }{ + // Safe inputs + {"Normal text", "Hello World", false}, + {"Name with apostrophe", "O'Connor", false}, + {"Hyphenated name", "Anne-Marie", false}, + + // Injection attempts + {"CRLF injection", "Name\r\nBcc: attacker@evil.com", true}, + {"LF injection", "Name\nBcc: attacker@evil.com", true}, + {"Content-Type header", "Name\r\nContent-Type: text/html", true}, + {"BCC header", "bcc: attacker@evil.com", true}, + {"CC header", "cc: attacker@evil.com", true}, + {"To header", "to: victim@example.com", true}, + {"From header", "from: fake@example.com", true}, + {"Reply-To header", "reply-to: attacker@evil.com", true}, + {"MIME-Version", "MIME-Version: 1.0", true}, + {"X-Mailer", "X-Mailer: Evil", true}, + {"Case insensitive", "BCC: attacker@evil.com", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContainsEmailInjection(tt.input); got != tt.want { + t.Errorf("ContainsEmailInjection(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// ============================================================================== +// NAME VALIDATION TESTS +// ============================================================================== + +func TestIsValidName(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // Valid names + {"Simple name", "John", true}, + {"Full name", "John Smith", true}, + {"Hyphenated", "Anne-Marie", true}, + {"Apostrophe", "O'Connor", true}, + {"Multiple words", "Juan José García", true}, + {"Spanish characters", "José María", true}, + {"French characters", "François Dubois", true}, + {"German characters", "Müller", true}, + + // Invalid names + {"Empty", "", false}, + {"Numbers", "John123", false}, + {"Special chars", "John@Smith", false}, + {"HTML tags", "", false}, + {"Email", "john@example.com", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidName(tt.input); got != tt.want { + t.Errorf("IsValidName(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// ============================================================================== +// SUBJECT VALIDATION TESTS +// ============================================================================== + +func TestIsValidSubject(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // Valid subjects + {"Simple", "Hello", true}, + {"With spaces", "Hello World", true}, + {"With punctuation", "Question about your services!", true}, + {"With numbers", "Order #12345", true}, + {"Complex", "Re: Your inquiry (urgent)", true}, + + // Invalid subjects + {"Empty", "", false}, + {"HTML tags", "", false}, + {"Email injection", "Subject\nBcc: evil@example.com", false}, + {"Special chars", "Subject $ % ^ &", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidSubject(tt.input); got != tt.want { + t.Errorf("IsValidSubject(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// ============================================================================== +// COMPANY VALIDATION TESTS +// ============================================================================== + +func TestIsValidCompany(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // Valid company names + {"Empty (optional)", "", true}, + {"Simple", "Acme Corp", true}, + {"With punctuation", "Smith & Sons, Inc.", true}, + {"With hyphen", "Coca-Cola", true}, + {"With parentheses", "Example (Spain)", true}, + + // Invalid company names + {"HTML tags", "\n\n\nHello World\n\n\n\n", + } + + SanitizeContactForm(req) + + // Check whitespace trimmed + if req.Name != "John Smith" { + t.Errorf("Name not properly sanitized: %q", req.Name) + } + + // Check newlines removed from headers + if strings.Contains(req.Email, "\n") || strings.Contains(req.Email, "\r") { + t.Errorf("Email still contains newlines: %q", req.Email) + } + + // Check HTML escaped + if !strings.Contains(req.Message, "<script>") { + t.Errorf("Message not properly HTML escaped: %q", req.Message) + } + + // Check whitespace normalized + if strings.Contains(req.Message, " ") { + t.Errorf("Message whitespace not normalized: %q", req.Message) + } +} + +// ============================================================================== +// FULL VALIDATION TESTS +// ============================================================================== + +func TestValidateContactForm(t *testing.T) { + tests := []struct { + name string + req *ContactFormRequest + wantError bool + errorField string + }{ + { + name: "Valid form", + req: &ContactFormRequest{ + Name: "John Smith", + Email: "john@example.com", + Company: "Acme Corp", + Subject: "Inquiry", + Message: "Hello, I have a question.", + Honeypot: "", + Timestamp: time.Now().Unix() - 10, // 10 seconds ago + }, + wantError: false, + }, + { + name: "Bot detected - honeypot filled", + req: &ContactFormRequest{ + Name: "Bot", + Email: "bot@example.com", + Subject: "Spam", + Message: "Spam message", + Honeypot: "http://evil.com", // Bot filled this + }, + wantError: true, + errorField: "website", + }, + { + name: "Bot detected - submitted too fast", + req: &ContactFormRequest{ + Name: "John", + Email: "john@example.com", + Subject: "Test", + Message: "Test", + Timestamp: time.Now().Unix(), // Just now (< 2 seconds) + }, + wantError: true, + errorField: "timestamp", + }, + { + name: "Missing required field - name", + req: &ContactFormRequest{ + Name: "", + Email: "john@example.com", + Subject: "Test", + Message: "Test", + }, + wantError: true, + errorField: "name", + }, + { + name: "Invalid email format", + req: &ContactFormRequest{ + Name: "John", + Email: "invalid-email", + Subject: "Test", + Message: "Test", + }, + wantError: true, + errorField: "email", + }, + { + name: "Email injection attempt", + req: &ContactFormRequest{ + Name: "Evil\nBcc: attacker@evil.com", + Email: "evil@example.com", + Subject: "Test", + Message: "Test", + }, + wantError: true, + errorField: "name", + }, + { + name: "Name too long", + req: &ContactFormRequest{ + Name: strings.Repeat("a", 101), + Email: "john@example.com", + Subject: "Test", + Message: "Test", + }, + wantError: true, + errorField: "name", + }, + { + name: "Subject too long", + req: &ContactFormRequest{ + Name: "John", + Email: "john@example.com", + Subject: strings.Repeat("a", 201), + Message: "Test", + }, + wantError: true, + errorField: "subject", + }, + { + name: "Message too long", + req: &ContactFormRequest{ + Name: "John", + Email: "john@example.com", + Subject: "Test", + Message: strings.Repeat("a", 5001), + }, + wantError: true, + errorField: "message", + }, + { + name: "Invalid name characters", + req: &ContactFormRequest{ + Name: "John123", + Email: "john@example.com", + Subject: "Test", + Message: "Test", + }, + wantError: true, + errorField: "name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateContactForm(tt.req) + + if tt.wantError { + if err == nil { + t.Errorf("Expected error but got none") + return + } + + // Check error is ValidationError + valErr, ok := err.(*ValidationError) + if !ok { + t.Errorf("Expected ValidationError, got %T", err) + return + } + + // Check correct field + if valErr.Field != tt.errorField { + t.Errorf("Expected error field %q, got %q", tt.errorField, valErr.Field) + } + } else { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + } + }) + } +} + +// ============================================================================== +// SECURITY TESTS (Attack Simulations) +// ============================================================================== + +func TestSecurityAttacks(t *testing.T) { + attacks := []struct { + name string + field string + value string + reason string + }{ + { + name: "SQL Injection in name", + field: "name", + value: "Robert'; DROP TABLE users; --", + reason: "Should reject SQL injection attempts", + }, + // NOTE: XSS in message is allowed during validation - it's escaped during sanitization + // This is intentional - we don't reject valid messages that happen to contain < or > + // The sanitization step handles HTML escaping before sending email + { + name: "Email header injection", + field: "email", + value: "test@example.com\nBcc: attacker@evil.com", + reason: "Should block email header injection", + }, + { + name: "Command injection", + field: "name", + value: "Test; rm -rf /", + reason: "Should block command injection attempts", + }, + { + name: "Path traversal", + field: "subject", + value: "../../../etc/passwd", + reason: "Should reject path traversal attempts", + }, + } + + for _, attack := range attacks { + t.Run(attack.name, func(t *testing.T) { + req := &ContactFormRequest{ + Name: "John Smith", + Email: "john@example.com", + Subject: "Test", + Message: "Test", + Timestamp: time.Now().Unix() - 10, + } + + // Inject attack into specified field + switch attack.field { + case "name": + req.Name = attack.value + case "email": + req.Email = attack.value + case "subject": + req.Subject = attack.value + case "message": + req.Message = attack.value + } + + err := ValidateContactForm(req) + + if err == nil { + t.Errorf("%s: Expected validation to fail, but it passed", attack.reason) + } + }) + } +} + +// ============================================================================== +// NORMALIZATION TESTS +// ============================================================================== + +func TestRemoveNewlines(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"No newlines", "No newlines"}, + {"With\nnewline", "Withnewline"}, + {"With\r\nCRLF", "WithCRLF"}, + {"Multiple\n\n\nnewlines", "Multiplenewlines"}, + } + + for _, tt := range tests { + if got := removeNewlines(tt.input); got != tt.want { + t.Errorf("removeNewlines(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestNormalizeWhitespace(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"Normal text", "Normal text"}, + {"Multiple spaces", "Multiple spaces"}, + {"Multiple\n\n\n\nnewlines", "Multiple\n\nnewlines"}, + {" Leading and trailing ", "Leading and trailing"}, + } + + for _, tt := range tests { + if got := normalizeWhitespace(tt.input); got != tt.want { + t.Errorf("normalizeWhitespace(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// ============================================================================== +// BENCHMARK TESTS +// ============================================================================== + +func BenchmarkIsValidEmail(b *testing.B) { + email := "test@example.com" + for i := 0; i < b.N; i++ { + IsValidEmail(email) + } +} + +func BenchmarkContainsEmailInjection(b *testing.B) { + text := "Normal text without injection" + for i := 0; i < b.N; i++ { + ContainsEmailInjection(text) + } +} + +func BenchmarkValidateContactForm(b *testing.B) { + req := &ContactFormRequest{ + Name: "John Smith", + Email: "john@example.com", + Company: "Acme Corp", + Subject: "Inquiry", + Message: "Hello, I have a question.", + Timestamp: time.Now().Unix() - 10, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = ValidateContactForm(req) + } +} diff --git a/main.go b/main.go index 518ae1f..2dfa9eb 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "github.com/juanatsap/cv-site/internal/config" "github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/routes" + "github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/templates" ) @@ -41,12 +42,23 @@ func main() { log.Fatalf("❌ Failed to initialize templates: %v", err) } + // Initialize email service + emailService := services.NewEmailService(&services.EmailConfig{ + SMTPHost: cfg.Email.SMTPHost, + SMTPPort: cfg.Email.SMTPPort, + SMTPUser: cfg.Email.SMTPUser, + SMTPPassword: cfg.Email.SMTPPassword, + FromEmail: cfg.Email.FromEmail, + ToEmail: cfg.Email.ContactEmail, + }) + // Initialize handlers cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address()) healthHandler := handlers.NewHealthHandler(version) + contactHandler := handlers.NewContactHandler(templateMgr, emailService) // Setup routes and middleware - handler := routes.Setup(cvHandler, healthHandler) + handler := routes.Setup(cvHandler, healthHandler, contactHandler) // Create server with timeouts server := &http.Server{ diff --git a/static/css/04-interactive/_contact-form.css b/static/css/04-interactive/_contact-form.css new file mode 100644 index 0000000..2353096 --- /dev/null +++ b/static/css/04-interactive/_contact-form.css @@ -0,0 +1,387 @@ +/* ============================================================================= + CONTACT FORM - Modal form styling + ============================================================================= */ + +/* Contact Modal Specific Overrides */ +#contact-modal { + max-width: 520px; +} + +#contact-modal .info-modal-cv-title { + color: #3498db; /* Blue subtitle for contact */ +} + +#contact-modal .info-modal-cv-title iconify-icon { + color: #3498db; +} + +/* Contact Modal Description */ +.contact-modal-description { + font-size: 0.95rem; + line-height: 1.6; + margin-bottom: 1.5rem; + color: #555; + text-align: center; +} + +/* ============================================================================= + FORM ELEMENTS + ============================================================================= */ + +/* Form Group - Wrapper for label + input */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-group:last-of-type { + margin-bottom: 1rem; +} + +/* Form Labels */ +.form-label { + display: block; + font-size: 0.9rem; + font-weight: 600; + color: #333; + margin-bottom: 0.4rem; +} + +.required-indicator { + color: #ef4444; + margin-left: 0.2rem; +} + +/* Form Inputs and Textarea */ +.form-input, +.form-textarea { + width: 100%; + padding: 0.75rem; + font-size: 0.95rem; + font-family: inherit; + border: 2px solid #e0e0e0; + border-radius: 8px; + background: #ffffff; + color: #333; + transition: all 0.2s ease; + box-sizing: border-box; +} + +.form-input:focus, +.form-textarea:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: #999; + opacity: 1; +} + +/* Textarea specific */ +.form-textarea { + resize: vertical; + min-height: 120px; + line-height: 1.5; +} + +/* Invalid state (HTML5 validation) */ +.form-input:invalid:not(:placeholder-shown), +.form-textarea:invalid:not(:placeholder-shown) { + border-color: #ef4444; +} + +.form-input:invalid:focus:not(:placeholder-shown), +.form-textarea:invalid:focus:not(:placeholder-shown) { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); +} + +/* ============================================================================= + CONTACT RESPONSE MESSAGES + ============================================================================= */ + +.contact-response { + margin-bottom: 1rem; + min-height: 0; +} + +.contact-message { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + margin-bottom: 1rem; + animation: messageSlideIn 0.3s ease; +} + +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.contact-message iconify-icon { + flex-shrink: 0; + margin-top: 0.1rem; +} + +.contact-message-content { + flex: 1; +} + +.contact-message-content strong { + display: block; + font-size: 0.95rem; + margin-bottom: 0.25rem; +} + +.contact-message-content p { + font-size: 0.875rem; + margin: 0; + line-height: 1.5; +} + +/* Success Message */ +.contact-success { + background: linear-gradient(135deg, rgba(40, 167, 69, 0.1) 0%, rgba(25, 135, 84, 0.05) 100%); + border: 2px solid rgba(40, 167, 69, 0.3); + color: #155724; +} + +.contact-success iconify-icon { + color: #28a745; +} + +/* Error Message */ +.contact-error { + background: linear-gradient(135deg, rgba(220, 53, 69, 0.1) 0%, rgba(200, 35, 51, 0.05) 100%); + border: 2px solid rgba(220, 53, 69, 0.3); + color: #721c24; +} + +.contact-error iconify-icon { + color: #dc3545; +} + +/* ============================================================================= + FORM ACTIONS (Submit Button) + ============================================================================= */ + +.form-actions { + margin-bottom: 0.75rem; +} + +.contact-submit-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.875rem 1.5rem; + font-size: 1rem; + font-weight: 600; + font-family: inherit; + border: none; + border-radius: 8px; + cursor: pointer; + background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); + color: white; + transition: all 0.2s ease; + box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); +} + +.contact-submit-btn:hover { + background: linear-gradient(135deg, #2980b9 0%, #3498db 100%); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(52, 152, 219, 0.4); +} + +.contact-submit-btn:active { + transform: translateY(0); + box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); +} + +.contact-submit-btn:disabled { + background: #e0e0e0; + color: #999; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Loading spinner in button */ +.contact-submit-btn .htmx-indicator { + display: none; +} + +.contact-submit-btn.htmx-request .htmx-indicator { + display: inline-flex; +} + +.contact-submit-btn.htmx-request > span { + opacity: 0.7; +} + +/* Spinning animation */ +.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ============================================================================= + FORM NOTE + ============================================================================= */ + +.form-note { + font-size: 0.8rem; + color: #666; + text-align: center; + margin: 0; + font-style: italic; +} + +/* ============================================================================= + CONTACT BUTTON (Fixed Position) + ============================================================================= */ + +/* Contact Button - positioned above bottom buttons */ +.contact-btn { + position: fixed; + bottom: 14rem; /* Above zoom button */ + left: 2rem; + width: 50px; + height: 50px; + background: var(--black-bar); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: all 0.3s ease; + z-index: 999; + opacity: 0.6; +} + +.contact-btn:hover { + opacity: 1; + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); + background: #3498db; /* Blue hover */ +} + +.contact-btn.at-bottom { + opacity: 1; + background: #3498db; /* Blue when at bottom */ +} + +/* ============================================================================= + RESPONSIVE DESIGN - CONTACT FORM + ============================================================================= */ + +/* Mobile adjustments */ +@media (max-width: 768px) { + #contact-modal { + max-width: calc(100vw - 2rem) !important; + width: calc(100vw - 2rem) !important; + max-height: calc(100vh - 2rem) !important; + } + + #contact-modal .info-modal-content { + padding: 1.5rem 1rem; + max-height: calc(100vh - 2rem); + overflow-y: auto; + } + + #contact-modal .info-modal-header h2 { + font-size: 1.25rem; + } + + #contact-modal .info-modal-cv-title { + font-size: 1rem; + } + + .contact-modal-description { + font-size: 0.875rem; + margin-bottom: 1.25rem; + } + + .form-group { + margin-bottom: 1rem; + } + + .form-label { + font-size: 0.85rem; + } + + .form-input, + .form-textarea { + font-size: 0.9rem; + padding: 0.65rem; + } + + .contact-submit-btn { + font-size: 0.95rem; + padding: 0.75rem 1.25rem; + } + + .form-note { + font-size: 0.75rem; + } + + /* Contact button mobile */ + .contact-btn { + bottom: 13.5rem; /* Adjust for mobile button spacing */ + left: 1.5rem; + width: 45px; + height: 45px; + } +} + +/* ============================================================================= + ACCESSIBILITY - REDUCED MOTION + ============================================================================= */ + +@media (prefers-reduced-motion: reduce) { + .contact-message { + animation: none; + } + + .spinning { + animation: none; + } + + .contact-submit-btn { + transition: none; + } + + .form-input, + .form-textarea { + transition: none; + } +} + +/* ============================================================================= + PRINT - HIDE CONTACT ELEMENTS + ============================================================================= */ + +@media print { + .contact-btn, + #contact-modal { + display: none !important; + } +} diff --git a/templates/partials/contact/contact-error.html b/templates/partials/contact/contact-error.html new file mode 100644 index 0000000..c30bbdd --- /dev/null +++ b/templates/partials/contact/contact-error.html @@ -0,0 +1,9 @@ +{{define "contact-error"}} +
+ +
+ {{.UI.ContactModal.Error.Title}} +

{{.ErrorMessage}}

+
+
+{{end}} diff --git a/templates/partials/contact/contact-success.html b/templates/partials/contact/contact-success.html new file mode 100644 index 0000000..21e1614 --- /dev/null +++ b/templates/partials/contact/contact-success.html @@ -0,0 +1,9 @@ +{{define "contact-success"}} +
+ +
+ {{.UI.ContactModal.Success.Title}} +

{{.UI.ContactModal.Success.Message}}

+
+
+{{end}} diff --git a/templates/partials/contact_error.html b/templates/partials/contact_error.html new file mode 100644 index 0000000..24c38a1 --- /dev/null +++ b/templates/partials/contact_error.html @@ -0,0 +1,31 @@ + + + diff --git a/templates/partials/contact_success.html b/templates/partials/contact_success.html new file mode 100644 index 0000000..4ee2b60 --- /dev/null +++ b/templates/partials/contact_success.html @@ -0,0 +1,36 @@ + + + diff --git a/templates/partials/modals/contact-modal.html b/templates/partials/modals/contact-modal.html new file mode 100644 index 0000000..b1499ad --- /dev/null +++ b/templates/partials/modals/contact-modal.html @@ -0,0 +1,162 @@ +{{define "contact-modal"}} + + +
+ + +
+

{{.UI.ContactModal.Title}}

+
+ + {{.UI.ContactModal.Subtitle}} +
+
+ +
+

+ {{.UI.ContactModal.Description}} +

+ +
+ + + + + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ +

{{.UI.ContactModal.Form.Note}}

+
+
+
+
+ + + +{{end}} diff --git a/templates/partials/navigation/hamburger-menu.html b/templates/partials/navigation/hamburger-menu.html index 400bb9d..c5ac521 100644 --- a/templates/partials/navigation/hamburger-menu.html +++ b/templates/partials/navigation/hamburger-menu.html @@ -191,6 +191,17 @@ {{.UI.Widgets.ActionButtons.PrintFriendly}} + + + + + + {{.UI.Widgets.ActionButtons.PlainText}} + diff --git a/templates/partials/widgets/contact-button.html b/templates/partials/widgets/contact-button.html new file mode 100644 index 0000000..7800178 --- /dev/null +++ b/templates/partials/widgets/contact-button.html @@ -0,0 +1,11 @@ +{{define "contact-button"}} + + +{{end}}