Add capcha

This commit is contained in:
bdnugget committed 2026-10-26 11:18:05 +01:00
1 parent cb5b679d54
commit 1fe2d5c57d
5 files changed
+150 -9

No files matched your search

+3
View File
@@ -21,6 +21,9 @@ When the site is deployed, the server will automatically serve everything from `
- Env variables:
- `PORT` (default `8080`)
- `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` (optional locally)
- `CAPCHA_SECRET` (Cap secret key, set in Coolify; contact form rejects submissions when missing)
- `CAPCHA_SITE_KEY` (default `d59925ffaa`)
- `CAPCHA_INSTANCE` (default `https://capcha.hogelandlinux.nl`)
- Dockerfile supports Coolify deployment (multi-stage build)
+93 -8
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -17,19 +18,24 @@ type Config struct {
Port string
TelegramBotToken string
TelegramChatID string
CapchaKeySecret string
CapchaSiteKey string
CapchaInstance string
}
type contactRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Subject string `json:"subject"`
Message string `json:"message"`
Name string `json:"name"`
Email string `json:"email"`
Subject string `json:"subject"`
Message string `json:"message"`
CapToken string `json:"capToken"`
}
var (
mu sync.Mutex
lastSubmissionTime = make(map[string]time.Time)
submissionCooldown = 2 * time.Minute
capHTTPClient = &http.Client{Timeout: 10 * time.Second}
)
func getEnv(key, def string) string {
@@ -120,6 +126,65 @@ func sendToTelegram(cfg Config, payload contactRequest) error {
return nil
}
// verifyCapcha verifies a Cap (trycap.dev) token with the configured instance.
// It returns an error when the token is missing, invalid, or cannot be verified.
func verifyCapcha(ctx context.Context, cfg Config, token string) error {
if token == "" {
return fmt.Errorf("missing cap-token")
}
if cfg.CapchaKeySecret == "" {
return fmt.Errorf("CAPCHA_SECRET is not configured")
}
verifyURL := strings.TrimRight(cfg.CapchaInstance, "/") + "/" +
strings.Trim(cfg.CapchaSiteKey, "/") + "/siteverify"
payload, err := json.Marshal(map[string]string{
"secret": cfg.CapchaKeySecret,
"response": token,
})
if err != nil {
return fmt.Errorf("failed to marshal captcha payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("failed to create captcha request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := capHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("failed to reach captcha service: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return fmt.Errorf("failed to read captcha response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("captcha service returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result struct {
Success bool `json:"success"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("failed to decode captcha response: %w", err)
}
if !result.Success {
if result.Error != "" {
return fmt.Errorf("captcha rejected: %s", result.Error)
}
return fmt.Errorf("captcha rejected")
}
return nil
}
func contactHandler(cfg Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -156,10 +221,11 @@ func contactHandler(cfg Config) http.HandlerFunc {
return
}
req = contactRequest{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Subject: strings.TrimSpace(r.FormValue("subject")),
Message: strings.TrimSpace(r.FormValue("message")),
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Subject: strings.TrimSpace(r.FormValue("subject")),
Message: strings.TrimSpace(r.FormValue("message")),
CapToken: strings.TrimSpace(r.FormValue("cap-token")),
}
}
@@ -173,6 +239,18 @@ func contactHandler(cfg Config) http.HandlerFunc {
return
}
// Verify the Cap token before any side effects (tokens are single-use)
if err := verifyCapcha(r.Context(), cfg, strings.TrimSpace(req.CapToken)); err != nil {
log.Printf("captcha verification failed for %s: %v", ip, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"message": "Human verification failed. Please refresh the page and try again.",
})
return
}
if err := sendToTelegram(cfg, req); err != nil {
log.Printf("telegram send failed: %v", err)
w.Header().Set("Content-Type", "application/json")
@@ -207,6 +285,13 @@ func main() {
Port: ":" + getEnv("PORT", "8080"),
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
TelegramChatID: getEnv("TELEGRAM_CHAT_ID", ""),
CapchaKeySecret: getEnv("CAPCHA_SECRET", ""),
CapchaSiteKey: getEnv("CAPCHA_SITE_KEY", "d59925ffaa"),
CapchaInstance: getEnv("CAPCHA_INSTANCE", "https://capcha.hogelandlinux.nl"),
}
if cfg.CapchaKeySecret == "" {
log.Printf("Warning: CAPCHA_SECRET is not set; contact form submissions will be rejected")
}
mux := http.NewServeMux()
+5
View File
@@ -16,6 +16,9 @@
<!-- Favicon -->
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<!-- Cap human verification widget -->
<script src="https://cdn.jsdelivr.net/npm/cap-widget@latest"></script>
</head>
<body>
@@ -300,6 +303,8 @@
<textarea id="message" name="message" class="form-textarea" rows="5" placeholder="Tell me about your project or inquiry..." required></textarea>
</div>
<cap-widget id="cap-widget" required data-cap-api-endpoint="https://capcha.hogelandlinux.nl/d59925ffaa/"></cap-widget>
<button type="submit" class="btn btn-primary btn-full">
Send Message
<img src="/static/icons/send.svg" alt="" class="btn-icon">
+35 -1
View File
@@ -59,9 +59,39 @@ if (themeToggle) {
// Contact form submission
const contactForm = document.getElementById('contact-form');
if (contactForm) {
const capWidget = document.getElementById('cap-widget');
let capToken = '';
if (capWidget) {
capWidget.addEventListener('solve', (e) => {
capToken = e.detail.token;
});
capWidget.addEventListener('reset', () => {
capToken = '';
});
capWidget.addEventListener('error', (e) => {
console.error('Human verification error:', e.detail && e.detail.message);
});
}
const resetCapWidget = () => {
capToken = '';
if (capWidget && typeof capWidget.reset === 'function') {
capWidget.reset();
}
};
contactForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Fall back to the hidden field the widget injects into the form
const hiddenTokenInput = contactForm.querySelector('input[name="cap-token"]');
const token = capToken || (hiddenTokenInput ? hiddenTokenInput.value : '');
if (!token) {
alert('Please complete the human verification before sending your message.');
return;
}
const submitButton = contactForm.querySelector('button[type="submit"]');
const originalText = submitButton ? submitButton.textContent : '';
if (submitButton) {
@@ -73,7 +103,8 @@ if (contactForm) {
name: document.getElementById('name').value.trim(),
email: document.getElementById('email').value.trim(),
subject: document.getElementById('subject').value.trim(),
message: document.getElementById('message').value.trim()
message: document.getElementById('message').value.trim(),
capToken: token
};
try {
@@ -85,12 +116,15 @@ if (contactForm) {
const data = await res.json().catch(() => ({ ok: false, message: 'Unexpected response' }));
if (!res.ok || data.ok === false) {
alert(data.message || 'Failed to send message. Please try again later.');
resetCapWidget();
} else {
alert('Thank you for your message! I\'ll get back to you soon.');
contactForm.reset();
resetCapWidget();
}
} catch (err) {
alert('Network error. Please try again later.');
resetCapWidget();
} finally {
if (submitButton) {
submitButton.disabled = false;
+14
View File
@@ -999,6 +999,20 @@
color: rgba(var(--foreground), 0.7);
}
/* Cap human verification widget */
cap-widget {
align-self: flex-start;
--cap-background: var(--background);
--cap-border-color: var(--border);
--cap-border-radius: var(--radius);
--cap-color: var(--foreground);
--cap-checkbox-background: var(--background);
--cap-checkbox-border: 1px solid var(--border);
--cap-spinner-color: var(--primary);
--cap-spinner-background-color: var(--border);
--cap-font: 'Inter', sans-serif;
}
.form-input,
.form-textarea {
width: 100%;