mirror of
https://github.com/chemistry-software/website.git
synced 2026-11-07 08:57:29 +00:00
Add capcha
This commit is contained in:
1 parent
cb5b679d54
commit
1fe2d5c57d
5 files changed
+150
-9
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in new issue
Block a user