From e2113a36135b9b04e3aa960c0791b2efd545c963 Mon Sep 17 00:00:00 2001 From: bdnugget Date: Thu, 21 Aug 2025 22:23:37 +0200 Subject: [PATCH] Add Go server for contact form to Telegram bot and serving static files, added Dockerfile for Coolify deployment, similar to hogelandlinux --- Dockerfile | 29 ++++ README.md | 48 ++++++- go.mod | 5 + main.go | 233 ++++++++++++++++++++++++++++++++ index.html => static/index.html | 6 +- script.js => static/script.js | 53 +++++--- styles.css => static/styles.css | 0 7 files changed, 352 insertions(+), 22 deletions(-) create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 main.go rename index.html => static/index.html (99%) rename script.js => static/script.js (71%) rename styles.css => static/styles.css (100%) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc15232 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +## Build stage +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum* ./ +RUN go mod download || true + +COPY . . + +# Build static binary +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server . + +## Final runtime image +FROM alpine:latest + +RUN apk --no-cache add ca-certificates +WORKDIR /srv + +COPY --from=builder /app/server ./server +COPY --from=builder /app/static ./static + +EXPOSE 8080 + +ENV PORT=8080 + +CMD ["/srv/server"] + + diff --git a/README.md b/README.md index 2501cfe..b66e659 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,47 @@ -# website +# chemistry.software website -chemistry.software website +## For editing the website (No Go, Docker or Coolify knowledge needed) + +All the web pages, styles, and JavaScript live in the `static/` folder: + +- `static/index.html` – the website page to edit +- `static/styles.css` – the visual styling (colors, layout, fonts) +- `static/script.js` – small behaviors like dark mode and the contact form + +You can just edit this and view index.html as you usually would. Contact form just does not submit unless you have the server running. +When the site is deployed, the server will automatically serve everything from `/static` and handle the contact form submission. + + +## For deployment + +- Backend is a small Go server (`main.go`) that serves: + - `GET /` → serves `static/index.html` + - `GET /static/*` → serves files from `static/` + - `POST /api/contact` → sends messages to Telegram +- Env variables: + - `PORT` (default `8080`) + - `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` (optional locally) +- Dockerfile supports Coolify deployment (multi-stage build) + + +## Running the server locally + +If you for some reason do need to run the server (Go required but not Docker), you can simply do: + +``` +go run . +``` +or to run with the environment vars for the Telegram bot to work: +``` +TELEGRAM_BOT_TOKEN=7890948543:AAHJoSZZU4l-jrWmqLcVCh0R74R9U_bn5ZE \ +TELEGRAM_CHAT_ID=126131628 \ +go run . +``` + +Or if you want to run the entire Docker image (Docker required but not Go): +```docker build -t chemistrysoftware-site . +docker run -p 8080:8080 \ + -e PORT=8080 \ + -e TELEGRAM_BOT_TOKEN=7890948543:AAHJoSZZU4l-jrWmqLcVCh0R74R9U_bn5ZE \ + -e TELEGRAM_CHAT_ID=126131628 \ + chemistrysoftware-site``` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a686907 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module chemistrysoftware/website + +go 1.22 + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..477a009 --- /dev/null +++ b/main.go @@ -0,0 +1,233 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "sync" + "time" +) + +type Config struct { + Port string + TelegramBotToken string + TelegramChatID string +} + +type contactRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Subject string `json:"subject"` + Message string `json:"message"` +} + +var ( + mu sync.Mutex + lastSubmissionTime = make(map[string]time.Time) + submissionCooldown = 2 * time.Minute +) + +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 +} + +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")), + } + } + + 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 + } + + 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", ""), + } + + 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)) +} diff --git a/index.html b/static/index.html similarity index 99% rename from index.html rename to static/index.html index 2b39bf0..3961352 100644 --- a/index.html +++ b/static/index.html @@ -11,10 +11,10 @@ - + - + @@ -618,6 +618,6 @@ - + diff --git a/script.js b/static/script.js similarity index 71% rename from script.js rename to static/script.js index caf5b31..ce0fb02 100644 --- a/script.js +++ b/static/script.js @@ -59,25 +59,44 @@ if (themeToggle) { // Contact form submission const contactForm = document.getElementById('contact-form'); if (contactForm) { - contactForm.addEventListener('submit', (e) => { + contactForm.addEventListener('submit', async (e) => { e.preventDefault(); - - // Get form data - const formData = { - name: document.getElementById('name').value, - email: document.getElementById('email').value, - subject: document.getElementById('subject').value, - message: document.getElementById('message').value + + const submitButton = contactForm.querySelector('button[type="submit"]'); + const originalText = submitButton ? submitButton.textContent : ''; + if (submitButton) { + submitButton.disabled = true; + submitButton.textContent = 'Sending...'; + } + + const payload = { + name: document.getElementById('name').value.trim(), + email: document.getElementById('email').value.trim(), + subject: document.getElementById('subject').value.trim(), + message: document.getElementById('message').value.trim() }; - - // Here you would typically send the data to a server - console.log('Form submitted:', formData); - - // Show success message - alert('Thank you for your message! I\'ll get back to you soon.'); - - // Reset form - contactForm.reset(); + + try { + const res = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await res.json().catch(() => ({ ok: false, message: 'Unexpected response' })); + if (!res.ok || data.ok === false) { + alert(data.message || 'Failed to send message. Please try again later.'); + } else { + alert('Thank you for your message! I\'ll get back to you soon.'); + contactForm.reset(); + } + } catch (err) { + alert('Network error. Please try again later.'); + } finally { + if (submitButton) { + submitButton.disabled = false; + submitButton.textContent = originalText; + } + } }); } diff --git a/styles.css b/static/styles.css similarity index 100% rename from styles.css rename to static/styles.css