diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 91a563a..f4f6672 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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) | | `TELEGRAM_BOT_TOKEN` | *(empty)* | Telegram bot token for contact form notifications | | `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) @@ -94,9 +97,10 @@ VILLAGE=Eenrum DOMAIN=hogelandlinux.nl TELEGRAM_BOT_TOKEN=your_bot_token_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 diff --git a/docker-compose.yml b/docker-compose.yml index 958d2d9..557925f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,9 @@ services: - DOMAIN=hogelandlinux.nl # Set your actual domain here - TELEGRAM_BOT_TOKEN= # Set your Telegram bot token 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 - BIRTH_DATE=1990-01-01 - SON_BIRTH_DATE=2022-01-01 diff --git a/main.go b/main.go index 243f087..d4dfa47 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "encoding/json" "fmt" "html/template" @@ -31,6 +32,9 @@ type Config struct { AboutName string BirthDate string SonBirthDate string + CapchaKeySecret string + CapchaSiteKey string + CapchaInstance string } // PageData holds data for template rendering @@ -59,8 +63,9 @@ type ContactForm struct { // Server holds the application state type Server struct { - config Config - templates *template.Template + config Config + templates *template.Template + httpClient *http.Client } // Rate limiting @@ -96,6 +101,13 @@ func NewServer() *Server { AboutName: getEnv("ABOUT_NAME", "Bdnugget"), BirthDate: getEnv("BIRTH_DATE", "1990-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 @@ -129,8 +141,9 @@ func NewServer() *Server { } return &Server{ - config: config, - templates: templates, + config: config, + 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 } + // 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 if s.config.TelegramBotToken != "" && s.config.TelegramChatID != "" { 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) } +// 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 func (s *Server) sendToTelegram(form ContactForm) error { // Format message diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..0d5ce36 --- /dev/null +++ b/main_test.go @@ -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 +} diff --git a/templates/contact.html b/templates/contact.html index f2a510d..16d90ee 100644 --- a/templates/contact.html +++ b/templates/contact.html @@ -121,6 +121,7 @@ ] } + {{template "header" .}} @@ -231,7 +232,7 @@ - +