Add captcha
This commit is contained in:
1 parent
0d79b1fbc1
commit
e457717465
5 files changed
+249
-6
No files matched your search
+5
-1
@@ -25,6 +25,9 @@ The application supports the following environment variables:
|
|||||||
| `DOMAIN` | `hogelandlinux.nl` | Your website domain (used for SEO and social media meta tags) |
|
| `DOMAIN` | `hogelandlinux.nl` | Your website domain (used for SEO and social media meta tags) |
|
||||||
| `TELEGRAM_BOT_TOKEN` | *(empty)* | Telegram bot token for contact form notifications |
|
| `TELEGRAM_BOT_TOKEN` | *(empty)* | Telegram bot token for contact form notifications |
|
||||||
| `TELEGRAM_CHAT_ID` | *(empty)* | Telegram chat ID where notifications will be sent |
|
| `TELEGRAM_CHAT_ID` | *(empty)* | Telegram chat ID where notifications will be sent |
|
||||||
|
| `CAPCHA_SECRET` | *(empty)* | Secret key (`sk-...`) from the Cap dashboard, used to verify contact form submissions. **Required** — submissions are rejected when unset |
|
||||||
|
| `CAPCHA_SITE_KEY` | `113476d322` | Public Cap site key used by the widget |
|
||||||
|
| `CAPCHA_INSTANCE` | `https://capcha.hogelandlinux.nl` | Base URL of the self-hosted Cap instance |
|
||||||
|
|
||||||
## Telegram Integration Setup (Optional)
|
## Telegram Integration Setup (Optional)
|
||||||
|
|
||||||
@@ -94,9 +97,10 @@ VILLAGE=Eenrum
|
|||||||
DOMAIN=hogelandlinux.nl
|
DOMAIN=hogelandlinux.nl
|
||||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
TELEGRAM_CHAT_ID=your_chat_id_here
|
TELEGRAM_CHAT_ID=your_chat_id_here
|
||||||
|
CAPCHA_SECRET=sk-your_cap_secret_key
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The Telegram variables are optional. If not set, the contact form will work but won't send notifications.
|
**Note:** The Telegram variables are optional. If not set, the contact form will work but won't send notifications. `CAPCHA_SECRET` is required: without it the contact form rejects every submission.
|
||||||
|
|
||||||
### 5. Configure Domain
|
### 5. Configure Domain
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ services:
|
|||||||
- DOMAIN=hogelandlinux.nl # Set your actual domain here
|
- DOMAIN=hogelandlinux.nl # Set your actual domain here
|
||||||
- TELEGRAM_BOT_TOKEN= # Set your Telegram bot token here
|
- TELEGRAM_BOT_TOKEN= # Set your Telegram bot token here
|
||||||
- TELEGRAM_CHAT_ID= # Set your Telegram chat ID here
|
- TELEGRAM_CHAT_ID= # Set your Telegram chat ID here
|
||||||
|
- CAPCHA_SECRET= # Set your Cap secret key (sk-...) here
|
||||||
|
- CAPCHA_SITE_KEY=113476d322
|
||||||
|
- CAPCHA_INSTANCE=https://capcha.hogelandlinux.nl
|
||||||
- ABOUT_NAME=Bdnugget
|
- ABOUT_NAME=Bdnugget
|
||||||
- BIRTH_DATE=1990-01-01
|
- BIRTH_DATE=1990-01-01
|
||||||
- SON_BIRTH_DATE=2022-01-01
|
- SON_BIRTH_DATE=2022-01-01
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -31,6 +32,9 @@ type Config struct {
|
|||||||
AboutName string
|
AboutName string
|
||||||
BirthDate string
|
BirthDate string
|
||||||
SonBirthDate string
|
SonBirthDate string
|
||||||
|
CapchaKeySecret string
|
||||||
|
CapchaSiteKey string
|
||||||
|
CapchaInstance string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageData holds data for template rendering
|
// PageData holds data for template rendering
|
||||||
@@ -59,8 +63,9 @@ type ContactForm struct {
|
|||||||
|
|
||||||
// Server holds the application state
|
// Server holds the application state
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config Config
|
config Config
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
@@ -96,6 +101,13 @@ func NewServer() *Server {
|
|||||||
AboutName: getEnv("ABOUT_NAME", "Bdnugget"),
|
AboutName: getEnv("ABOUT_NAME", "Bdnugget"),
|
||||||
BirthDate: getEnv("BIRTH_DATE", "1990-01-01"),
|
BirthDate: getEnv("BIRTH_DATE", "1990-01-01"),
|
||||||
SonBirthDate: getEnv("SON_BIRTH_DATE", "2022-01-01"),
|
SonBirthDate: getEnv("SON_BIRTH_DATE", "2022-01-01"),
|
||||||
|
CapchaKeySecret: getEnv("CAPCHA_SECRET", ""), // Set in Coolify env
|
||||||
|
CapchaSiteKey: getEnv("CAPCHA_SITE_KEY", "113476d322"),
|
||||||
|
CapchaInstance: getEnv("CAPCHA_INSTANCE", "https://capcha.hogelandlinux.nl"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.CapchaKeySecret == "" {
|
||||||
|
log.Printf("Warning: CAPCHA_SECRET is not set; contact form submissions will be rejected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template Funcs
|
// Template Funcs
|
||||||
@@ -129,8 +141,9 @@ func NewServer() *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
config: config,
|
config: config,
|
||||||
templates: templates,
|
templates: templates,
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +244,14 @@ func (s *Server) handleContactForm(w http.ResponseWriter, r *http.Request, data
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the Cap token before any side effects (tokens are single-use)
|
||||||
|
if err := s.verifyCapcha(r.Context(), r.FormValue("cap-token")); err != nil {
|
||||||
|
log.Printf("Captcha verification failed for %s: %v", ip, err)
|
||||||
|
data.ErrorMessage = "De mens-verificatie is mislukt. Ververs de pagina en probeer het opnieuw, of stuur een mailtje."
|
||||||
|
s.renderTemplate(w, "contact.html", *data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Send to Telegram if configured
|
// Send to Telegram if configured
|
||||||
if s.config.TelegramBotToken != "" && s.config.TelegramChatID != "" {
|
if s.config.TelegramBotToken != "" && s.config.TelegramChatID != "" {
|
||||||
err := s.sendToTelegram(form)
|
err := s.sendToTelegram(form)
|
||||||
@@ -253,6 +274,65 @@ func (s *Server) handleContactForm(w http.ResponseWriter, r *http.Request, data
|
|||||||
s.renderTemplate(w, "contact.html", *data)
|
s.renderTemplate(w, "contact.html", *data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (s *Server) verifyCapcha(ctx context.Context, token string) error {
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("missing cap-token")
|
||||||
|
}
|
||||||
|
if s.config.CapchaKeySecret == "" {
|
||||||
|
return fmt.Errorf("CAPCHA_SECRET is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyURL := strings.TrimRight(s.config.CapchaInstance, "/") + "/" +
|
||||||
|
strings.Trim(s.config.CapchaSiteKey, "/") + "/siteverify"
|
||||||
|
|
||||||
|
payload, err := json.Marshal(map[string]string{
|
||||||
|
"secret": s.config.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 := s.httpClient.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
|
||||||
|
}
|
||||||
|
|
||||||
// sendToTelegram sends the contact form data to Telegram
|
// sendToTelegram sends the contact form data to Telegram
|
||||||
func (s *Server) sendToTelegram(form ContactForm) error {
|
func (s *Server) sendToTelegram(form ContactForm) error {
|
||||||
// Format message
|
// Format message
|
||||||
|
|||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifyCapcha(t *testing.T) {
|
||||||
|
t.Run("valid token", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/113476d322/siteverify" {
|
||||||
|
t.Errorf("path = %s, want /113476d322/siteverify", r.URL.Path)
|
||||||
|
}
|
||||||
|
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Errorf("content type = %s, want application/json", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decoding payload: %v", err)
|
||||||
|
}
|
||||||
|
if payload["secret"] != "sk-test" {
|
||||||
|
t.Errorf("secret = %q, want sk-test", payload["secret"])
|
||||||
|
}
|
||||||
|
if payload["response"] != "token-123" {
|
||||||
|
t.Errorf("response = %q, want token-123", payload["response"])
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := newTestServer()
|
||||||
|
s.config.CapchaInstance = srv.URL
|
||||||
|
s.config.CapchaSiteKey = "113476d322"
|
||||||
|
s.config.CapchaKeySecret = "sk-test"
|
||||||
|
|
||||||
|
if err := s.verifyCapcha(t.Context(), "token-123"); err != nil {
|
||||||
|
t.Fatalf("verifyCapcha returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejected token", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"invalid token"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := newTestServer()
|
||||||
|
s.config.CapchaInstance = srv.URL
|
||||||
|
s.config.CapchaKeySecret = "sk-test"
|
||||||
|
|
||||||
|
err := s.verifyCapcha(t.Context(), "bad-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("verifyCapcha succeeded, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid token") {
|
||||||
|
t.Errorf("error = %q, want it to contain the service error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing token", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Error("captcha service should not be called without a token")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := newTestServer()
|
||||||
|
s.config.CapchaInstance = srv.URL
|
||||||
|
s.config.CapchaKeySecret = "sk-test"
|
||||||
|
|
||||||
|
if err := s.verifyCapcha(t.Context(), ""); err == nil {
|
||||||
|
t.Fatal("verifyCapcha succeeded, want error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing secret", func(t *testing.T) {
|
||||||
|
s := newTestServer()
|
||||||
|
s.config.CapchaKeySecret = ""
|
||||||
|
|
||||||
|
if err := s.verifyCapcha(t.Context(), "token-123"); err == nil {
|
||||||
|
t.Fatal("verifyCapcha succeeded, want error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("service error", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := newTestServer()
|
||||||
|
s.config.CapchaInstance = srv.URL
|
||||||
|
s.config.CapchaKeySecret = "sk-test"
|
||||||
|
|
||||||
|
if err := s.verifyCapcha(t.Context(), "token-123"); err == nil {
|
||||||
|
t.Fatal("verifyCapcha succeeded, want error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContactFormRequiresCapToken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"invalid token"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := newTestServer()
|
||||||
|
s.config.CapchaInstance = srv.URL
|
||||||
|
s.config.CapchaKeySecret = "sk-test"
|
||||||
|
s.config.TelegramBotToken = ""
|
||||||
|
s.config.TelegramChatID = ""
|
||||||
|
|
||||||
|
// Forged submission without a cap-token must be rejected.
|
||||||
|
body := url.Values{
|
||||||
|
"name": {"Test"},
|
||||||
|
"email": {"test@example.com"},
|
||||||
|
"message": {"Hallo"},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(body.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("X-Forwarded-For", "203.0.113.10")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
s.contactHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
respBody := rec.Body.String()
|
||||||
|
if strings.Contains(respBody, "Bedankt voor uw bericht") {
|
||||||
|
t.Fatal("submission without a cap-token was accepted")
|
||||||
|
}
|
||||||
|
if !strings.Contains(respBody, "mens-verificatie is mislukt") {
|
||||||
|
t.Fatalf("expected verification error message, got: %s", respBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServer() *Server {
|
||||||
|
s := NewServer()
|
||||||
|
// Keep tests independent from any ambient Telegram configuration.
|
||||||
|
s.config.TelegramBotToken = ""
|
||||||
|
s.config.TelegramChatID = ""
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -121,6 +121,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/cap-widget@latest"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
@@ -231,7 +232,7 @@
|
|||||||
<label for="message">Bericht *</label>
|
<label for="message">Bericht *</label>
|
||||||
<textarea id="message" name="message" rows="5" required placeholder="Vertel iets over uw situatie en hoe ik u kan helpen...">{{.FormData.Message}}</textarea>
|
<textarea id="message" name="message" rows="5" required placeholder="Vertel iets over uw situatie en hoe ik u kan helpen...">{{.FormData.Message}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<cap-widget required data-cap-api-endpoint="{{.CapchaInstance}}/{{.CapchaSiteKey}}/"></cap-widget>
|
||||||
<button type="submit" class="btn btn-primary">Verstuur bericht</button>
|
<button type="submit" class="btn btn-primary">Verstuur bericht</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user