From 1fe2d5c57d0459122f7553e91283897f25a4fc27 Mon Sep 17 00:00:00 2001 From: Bd Nugget Date: Mon, 26 Oct 2026 11:18:05 +0100 Subject: [PATCH] Add capcha --- README.md | 3 ++ main.go | 101 ++++++++++++++++++++++++++++++++++++++++++---- static/index.html | 5 +++ static/script.js | 36 ++++++++++++++++- static/styles.css | 14 +++++++ 5 files changed, 150 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b66e659..6fc04ab 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/main.go b/main.go index 477a009..a5c4dd1 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/static/index.html b/static/index.html index 99a37e0..8546e87 100644 --- a/static/index.html +++ b/static/index.html @@ -16,6 +16,9 @@ + + + @@ -300,6 +303,8 @@ + +