mirror of
https://github.com/chemistry-software/website.git
synced 2026-11-07 08:57:29 +00:00
319 lines
8.3 KiB
Go
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))
|
|
}
|