Files
2026-10-26 11:18:05 +01:00

319 lines
8.3 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
)
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"`
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 {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
return r.RemoteAddr[:idx]
}
return r.RemoteAddr
}
func escapeMarkdown(text string) string {
if text == "" {
return "N/A"
}
replacer := strings.NewReplacer(
"*", "\\*",
"_", "\\_",
"`", "\\`",
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"~", "\\~",
">", "\\>",
"#", "\\#",
"+", "\\+",
"-", "\\-",
"=", "\\=",
"|", "\\|",
"{", "\\{",
"}", "\\}",
".", "\\.",
"!", "\\!",
)
return replacer.Replace(text)
}
func sendToTelegram(cfg Config, payload contactRequest) error {
if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" {
return nil
}
message := fmt.Sprintf(
"📨 New contact request\n\n"+
"👤 *Name:* %s\n"+
"📧 *Email:* %s\n"+
"📝 *Subject:* %s\n\n"+
"💬 *Message:*\n%s",
escapeMarkdown(payload.Name),
escapeMarkdown(payload.Email),
escapeMarkdown(payload.Subject),
escapeMarkdown(payload.Message),
)
telegramURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.TelegramBotToken)
body := map[string]any{
"chat_id": cfg.TelegramChatID,
"text": message,
"parse_mode": "Markdown",
}
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal telegram body: %w", err)
}
resp, err := http.Post(telegramURL, "application/json", bytes.NewBuffer(data))
if err != nil {
return fmt.Errorf("telegram post failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("telegram api error: status=%d body=%s", resp.StatusCode, string(b))
}
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 {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
ip := getClientIP(r)
mu.Lock()
if last, ok := lastSubmissionTime[ip]; ok {
if time.Since(last) < submissionCooldown {
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"message": "Please wait a few minutes before sending another message.",
})
return
}
}
mu.Unlock()
var req contactRequest
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "application/json") {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
} else {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
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")),
CapToken: strings.TrimSpace(r.FormValue("cap-token")),
}
}
if req.Name == "" || req.Email == "" || req.Subject == "" || req.Message == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"message": "Missing required fields.",
})
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")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"message": "Failed to send message. Please try again later.",
})
return
}
mu.Lock()
lastSubmissionTime[ip] = time.Now()
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"message": "Thank you for your message!",
})
}
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"healthy","service":"chemistrysoftware"}`))
}
func main() {
cfg := Config{
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()
// API routes
mux.HandleFunc("/api/contact", contactHandler(cfg))
mux.HandleFunc("/health", healthHandler)
// Static assets
staticFS := http.FileServer(http.Dir("static"))
mux.Handle("/static/", http.StripPrefix("/static/", staticFS))
// Root page
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
http.ServeFile(w, r, "static/index.html")
return
}
http.NotFound(w, r)
})
log.Printf("Server starting on %s", cfg.Port)
log.Fatal(http.ListenAndServe(cfg.Port, mux))
}