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:
@@ -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