feat: Add plain text CV endpoint and contact form with security
Plain text endpoint: - Add /text route for plain text CV (for curl/AI crawlers) - Use k3a/html2text library for HTML-to-text conversion - Add Plain Text button to hamburger menu with UI translations Contact form feature: - Add ContactHandler with proper email service integration - Add CSRF protection middleware - Add rate limiting (5 submissions/hour per IP) - Add honeypot and timing-based bot protection - Add input validation with detailed error messages - Add security logging middleware - Add browser-only middleware for API protection Code quality: - Fix all golangci-lint errcheck warnings for w.Write calls - Remove duplicate getClientIP functions - Wire up ContactHandler in routes.Setup
This commit is contained in:
@@ -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
|
||||
|
||||
+35
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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(`<div class="alert alert-success">
|
||||
<h3>Message Sent!</h3>
|
||||
<p>Thank you for your message. I'll get back to you soon.</p>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, nil); err != nil {
|
||||
log.Printf("ERROR rendering success template: %v", err)
|
||||
_, _ = w.Write([]byte(`<div class="alert alert-success">
|
||||
<h3>Message Sent!</h3>
|
||||
<p>Thank you for your message. I'll get back to you soon.</p>
|
||||
</div>`))
|
||||
}
|
||||
}
|
||||
|
||||
// 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(`<div class="alert alert-error">
|
||||
<h3>Error</h3>
|
||||
<p>` + message + `</p>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Printf("ERROR rendering error template: %v", err)
|
||||
_, _ = w.Write([]byte(`<div class="alert alert-error">
|
||||
<h3>Error</h3>
|
||||
<p>` + message + `</p>
|
||||
</div>`))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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(`<div class="contact-message contact-success">
|
||||
<iconify-icon icon="mdi:check-circle" width="24" height="24"></iconify-icon>
|
||||
<div class="contact-message-content">
|
||||
<strong>Message Sent!</strong>
|
||||
<p>Thank you for your message. I'll get back to you soon.</p>
|
||||
</div>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Printf("Error rendering contact success template: %v", err)
|
||||
_, _ = w.Write([]byte(`<div class="contact-message contact-success">
|
||||
<iconify-icon icon="mdi:check-circle" width="24" height="24"></iconify-icon>
|
||||
<div class="contact-message-content">
|
||||
<strong>Message Sent!</strong>
|
||||
<p>Thank you for your message. I'll get back to you soon.</p>
|
||||
</div>
|
||||
</div>`))
|
||||
}
|
||||
}
|
||||
|
||||
// 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(`<div class="contact-message contact-error">
|
||||
<iconify-icon icon="mdi:alert-circle" width="24" height="24"></iconify-icon>
|
||||
<div class="contact-message-content">
|
||||
<strong>Error</strong>
|
||||
<p>` + errorMessage + `</p>
|
||||
</div>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Printf("Error rendering contact error template: %v", err)
|
||||
_, _ = w.Write([]byte(`<div class="contact-message contact-error">
|
||||
<iconify-icon icon="mdi:alert-circle" width="24" height="24"></iconify-icon>
|
||||
<div class="contact-message-content">
|
||||
<strong>Error</strong>
|
||||
<p>` + errorMessage + `</p>
|
||||
</div>
|
||||
</div>`))
|
||||
}
|
||||
}
|
||||
|
||||
// Note: getClientIP is defined in contact.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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(`<div class="alert alert-error">
|
||||
<h3>Too Many Requests</h3>
|
||||
<p>You've submitted too many contact forms. Please wait an hour before trying again.</p>
|
||||
</div>`))
|
||||
} 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",
|
||||
}
|
||||
}
|
||||
@@ -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(`<div class="alert alert-error">
|
||||
<h3>Security Error</h3>
|
||||
<p>Invalid security token. Please refresh the page and try again.</p>
|
||||
</div>`))
|
||||
} 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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", "<script>alert(1)</script>", 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", "<script>alert(1)</script>", 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", "<script>", false},
|
||||
{"Email injection", "Company\nBcc: evil@example.com", false},
|
||||
{"Special chars", "Company$$$", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValidCompany(tt.input); got != tt.want {
|
||||
t.Errorf("IsValidCompany(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// SANITIZATION TESTS
|
||||
// ==============================================================================
|
||||
|
||||
func TestSanitizeContactForm(t *testing.T) {
|
||||
req := &ContactFormRequest{
|
||||
Name: " John \n Smith ",
|
||||
Email: " john@example.com \r\n",
|
||||
Company: " Acme Corp ",
|
||||
Subject: " Test Subject ",
|
||||
Message: "<script>alert('XSS')</script>\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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "contact-error"}}
|
||||
<div class="contact-message contact-error">
|
||||
<iconify-icon icon="mdi:alert-circle" width="24" height="24"></iconify-icon>
|
||||
<div class="contact-message-content">
|
||||
<strong>{{.UI.ContactModal.Error.Title}}</strong>
|
||||
<p>{{.ErrorMessage}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "contact-success"}}
|
||||
<div class="contact-message contact-success">
|
||||
<iconify-icon icon="mdi:check-circle" width="24" height="24"></iconify-icon>
|
||||
<div class="contact-message-content">
|
||||
<strong>{{.UI.ContactModal.Success.Title}}</strong>
|
||||
<p>{{.UI.ContactModal.Success.Message}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,31 @@
|
||||
<div class="alert alert-error" role="alert" style="
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
border: 2px solid #ef4444;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #fef2f2;
|
||||
color: #991b1b;
|
||||
animation: shake 0.4s ease-out;
|
||||
">
|
||||
<div style="display: flex; align-items: start; gap: 1rem;">
|
||||
<svg style="flex-shrink: 0; width: 1.5rem; height: 1.5rem; margin-top: 0.125rem;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 0.5rem 0; font-size: 1.125rem; font-weight: 600;">
|
||||
Unable to Send Message
|
||||
</h3>
|
||||
<p style="margin: 0; font-size: 0.95rem; line-height: 1.5;">
|
||||
{{.Message}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="alert alert-success" role="alert" style="
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
border: 2px solid #22c55e;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #f0fdf4;
|
||||
color: #166534;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
">
|
||||
<div style="display: flex; align-items: start; gap: 1rem;">
|
||||
<svg style="flex-shrink: 0; width: 1.5rem; height: 1.5rem; margin-top: 0.125rem;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 0.5rem 0; font-size: 1.125rem; font-weight: 600;">
|
||||
Message Sent Successfully!
|
||||
</h3>
|
||||
<p style="margin: 0; font-size: 0.95rem; line-height: 1.5;">
|
||||
Thank you for reaching out. I've received your message and will get back to you as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
{{define "contact-modal"}}
|
||||
<!-- Contact Form Modal - Native Dialog -->
|
||||
<dialog id="contact-modal" class="info-modal no-print"
|
||||
_="on click call closeOnBackdrop(me, event)">
|
||||
<div class="info-modal-content">
|
||||
<button class="info-modal-close" onclick="document.getElementById('contact-modal').close()" aria-label="{{.UI.ContactModal.Close}}">
|
||||
<iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
|
||||
</button>
|
||||
|
||||
<div class="info-modal-header">
|
||||
<h2>{{.UI.ContactModal.Title}}</h2>
|
||||
<div class="info-modal-cv-title">
|
||||
<iconify-icon icon="mdi:email-outline" width="32" height="32"></iconify-icon>
|
||||
{{.UI.ContactModal.Subtitle}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-modal-body">
|
||||
<p class="contact-modal-description">
|
||||
{{.UI.ContactModal.Description}}
|
||||
</p>
|
||||
|
||||
<form id="contact-form"
|
||||
hx-post="/api/contact?lang={{.Lang}}"
|
||||
hx-target="#contact-response"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#contact-spinner"
|
||||
hx-headers='{"X-Requested-With": "htmx"}'
|
||||
_="on htmx:afterRequest
|
||||
if event.detail.successful
|
||||
wait 2s then call document.getElementById('contact-modal').close()
|
||||
end">
|
||||
|
||||
<!-- Honeypot field - hidden, should be empty -->
|
||||
<div style="position: absolute; left: -9999px;" aria-hidden="true">
|
||||
<label for="contact-website">Website</label>
|
||||
<input type="text"
|
||||
name="website"
|
||||
id="contact-website"
|
||||
tabindex="-1"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Timing field - set via JavaScript on page load -->
|
||||
<input type="hidden" name="form_loaded_at" id="contact-form-loaded-at">
|
||||
|
||||
<!-- Email (required) -->
|
||||
<div class="form-group">
|
||||
<label for="contact-email" class="form-label">
|
||||
{{.UI.ContactModal.Form.Email}} <span class="required-indicator">*</span>
|
||||
</label>
|
||||
<input type="email"
|
||||
id="contact-email"
|
||||
name="email"
|
||||
class="form-input"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="{{.UI.ContactModal.Form.EmailPlaceholder}}"
|
||||
aria-required="true">
|
||||
</div>
|
||||
|
||||
<!-- Name (optional) -->
|
||||
<div class="form-group">
|
||||
<label for="contact-name" class="form-label">
|
||||
{{.UI.ContactModal.Form.Name}}
|
||||
</label>
|
||||
<input type="text"
|
||||
id="contact-name"
|
||||
name="name"
|
||||
class="form-input"
|
||||
autocomplete="name"
|
||||
placeholder="{{.UI.ContactModal.Form.NamePlaceholder}}">
|
||||
</div>
|
||||
|
||||
<!-- Company (optional) -->
|
||||
<div class="form-group">
|
||||
<label for="contact-company" class="form-label">
|
||||
{{.UI.ContactModal.Form.Company}}
|
||||
</label>
|
||||
<input type="text"
|
||||
id="contact-company"
|
||||
name="company"
|
||||
class="form-input"
|
||||
autocomplete="organization"
|
||||
placeholder="{{.UI.ContactModal.Form.CompanyPlaceholder}}">
|
||||
</div>
|
||||
|
||||
<!-- Subject (optional) -->
|
||||
<div class="form-group">
|
||||
<label for="contact-subject" class="form-label">
|
||||
{{.UI.ContactModal.Form.Subject}}
|
||||
</label>
|
||||
<input type="text"
|
||||
id="contact-subject"
|
||||
name="subject"
|
||||
class="form-input"
|
||||
placeholder="{{.UI.ContactModal.Form.SubjectPlaceholder}}">
|
||||
</div>
|
||||
|
||||
<!-- Message (required) -->
|
||||
<div class="form-group">
|
||||
<label for="contact-message" class="form-label">
|
||||
{{.UI.ContactModal.Form.Message}} <span class="required-indicator">*</span>
|
||||
</label>
|
||||
<textarea id="contact-message"
|
||||
name="message"
|
||||
class="form-textarea"
|
||||
required
|
||||
rows="5"
|
||||
placeholder="{{.UI.ContactModal.Form.MessagePlaceholder}}"
|
||||
aria-required="true"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Response area for success/error messages -->
|
||||
<div id="contact-response" class="contact-response" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- Submit button with loading indicator -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="contact-submit-btn">
|
||||
<iconify-icon icon="mdi:send" width="20" height="20"></iconify-icon>
|
||||
<span>{{.UI.ContactModal.Form.Submit}}</span>
|
||||
<iconify-icon id="contact-spinner"
|
||||
icon="mdi:loading"
|
||||
class="htmx-indicator spinning"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-label="{{.UI.ContactModal.Form.Sending}}"></iconify-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="form-note">{{.UI.ContactModal.Form.Note}}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Initialize form timestamp on page load -->
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const timestampField = document.getElementById('contact-form-loaded-at');
|
||||
if (timestampField) {
|
||||
timestampField.value = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
// Reset timestamp when modal opens
|
||||
const contactModal = document.getElementById('contact-modal');
|
||||
if (contactModal) {
|
||||
contactModal.addEventListener('click', function(e) {
|
||||
if (e.target === contactModal && contactModal.open) {
|
||||
const timestampField = document.getElementById('contact-form-loaded-at');
|
||||
if (timestampField) {
|
||||
timestampField.value = Date.now();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -191,6 +191,17 @@
|
||||
<iconify-icon icon="mdi:leaf" width="20" height="20"></iconify-icon>
|
||||
<span>{{.UI.Widgets.ActionButtons.PrintFriendly}}</span>
|
||||
</button>
|
||||
|
||||
<button class="menu-action-btn menu-contact-btn"
|
||||
onclick="document.getElementById('contact-modal').showModal()">
|
||||
<iconify-icon icon="mdi:email-outline" width="20" height="20"></iconify-icon>
|
||||
<span>{{.UI.Widgets.ActionButtons.Contact}}</span>
|
||||
</button>
|
||||
|
||||
<a href="/text?lang={{.Lang}}" class="menu-action-btn menu-text-btn" target="_blank">
|
||||
<iconify-icon icon="mdi:text-box-outline" width="20" height="20"></iconify-icon>
|
||||
<span>{{.UI.Widgets.ActionButtons.PlainText}}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{{define "contact-button"}}
|
||||
<!-- Contact Button (Fixed Left) -->
|
||||
<button
|
||||
id="contact-button"
|
||||
class="fixed-btn contact-btn no-print has-tooltip"
|
||||
onclick="document.getElementById('contact-modal').showModal()"
|
||||
aria-label="{{.UI.Widgets.Contact.AriaLabel}}"
|
||||
data-tooltip="{{.UI.Widgets.Contact.Tooltip}}">
|
||||
<iconify-icon icon="mdi:email-outline" width="28" height="28"></iconify-icon>
|
||||
</button>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user