Files
linuxservice/main.go
T
2026-10-26 00:47:01 +01:00

559 lines
16 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Configuration holds all website configuration
type Config struct {
CompanyName string
KVK string
Email string
Phone string
Street string
PostalCode string
Village string
Domain string
Port string
TelegramBotToken string
TelegramChatID string
AboutName string
BirthDate string
SonBirthDate string
CapchaKeySecret string
CapchaSiteKey string
CapchaInstance string
}
// PageData holds data for template rendering
type PageData struct {
Config
Title string
CurrentPage string
CurrentYear int
ErrorMessage string
SuccessMessage string
FormData ContactForm
AboutName string
AgeYears int
SonAgeYears int
}
// ContactForm holds form data
type ContactForm struct {
Name string
Email string
Phone string
Computer string
Service string
Message string
}
// Server holds the application state
type Server struct {
config Config
templates *template.Template
httpClient *http.Client
}
// Rate limiting
var (
mu sync.Mutex
lastSubmissionTime = make(map[string]time.Time)
submissionCooldown = 5 * time.Minute
)
// getEnv returns environment variable or default value
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// NewServer creates a new server instance
func NewServer() *Server {
// Configuration - can be overridden with environment variables
config := Config{
CompanyName: getEnv("COMPANY_NAME", "Hogeland Linux"),
KVK: getEnv("KVK", "91927935"), // Replace with actual KVK number
Email: getEnv("EMAIL", "info@chemistry.software"),
Phone: getEnv("PHONE", "+31 6 12345678"),
Street: getEnv("STREET", "Voorstraat 123"),
PostalCode: getEnv("POSTAL_CODE", "9967 AA"),
Village: getEnv("VILLAGE", "Eenrum"),
Domain: getEnv("DOMAIN", "hogelandlinux.nl"), // Replace with actual domain
Port: ":" + getEnv("PORT", "8080"),
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""), // Set this in environment
TelegramChatID: getEnv("TELEGRAM_CHAT_ID", ""), // Set this in environment
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
funcs := template.FuncMap{
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict expects even number of args")
}
m := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
k, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
m[k] = values[i+1]
}
return m, nil
},
}
// Parse templates with error handling
templates, err := template.New("").Funcs(funcs).ParseGlob("templates/*.html")
if err != nil {
log.Fatalf("Failed to parse templates: %v", err)
}
// Optionally parse partials if any exist
if matches, _ := filepath.Glob("templates/partials/*.html"); len(matches) > 0 {
if _, err := templates.ParseFiles(matches...); err != nil {
log.Fatalf("Failed to parse partial templates: %v", err)
}
}
return &Server{
config: config,
templates: templates,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// cleanupOldSubmissions periodically purges stale rate-limit entries
func cleanupOldSubmissions() {
for {
time.Sleep(10 * time.Minute)
mu.Lock()
cutoff := time.Now().Add(-submissionCooldown)
for ip, t := range lastSubmissionTime {
if t.Before(cutoff) {
delete(lastSubmissionTime, ip)
}
}
mu.Unlock()
}
}
// createPageData creates PageData with the given title and current page
func (s *Server) createPageData(title, currentPage string) PageData {
return PageData{
Config: s.config,
Title: title,
CurrentPage: currentPage,
CurrentYear: time.Now().Year(),
}
}
// renderTemplate renders a template with error handling
func (s *Server) renderTemplate(w http.ResponseWriter, templateName string, data PageData) {
if err := s.templates.ExecuteTemplate(w, templateName, data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// homeHandler handles the home page
func (s *Server) homeHandler(w http.ResponseWriter, r *http.Request) {
data := s.createPageData("Linux-migratieservice - uw computer nieuw leven geven", "home")
s.renderTemplate(w, "index.html", data)
}
// contactHandler handles the contact page
func (s *Server) contactHandler(w http.ResponseWriter, r *http.Request) {
data := s.createPageData("Contact - "+s.config.CompanyName, "contact")
if r.Method == "POST" {
s.handleContactForm(w, r, &data)
return
}
s.renderTemplate(w, "contact.html", data)
}
// handleContactForm processes the contact form submission
func (s *Server) handleContactForm(w http.ResponseWriter, r *http.Request, data *PageData) {
// Get client IP for rate limiting
ip := getClientIP(r)
// Check rate limiting
mu.Lock()
if lastTime, exists := lastSubmissionTime[ip]; exists {
if time.Since(lastTime) < submissionCooldown {
mu.Unlock()
data.ErrorMessage = "U hebt recent al een bericht verstuurd. Probeer het over een paar minuten opnieuw."
s.renderTemplate(w, "contact.html", *data)
return
}
}
mu.Unlock()
// Parse form data
err := r.ParseForm()
if err != nil {
log.Printf("Error parsing form: %v", err)
data.ErrorMessage = "Er is een fout opgetreden bij het verwerken van uw bericht."
s.renderTemplate(w, "contact.html", *data)
return
}
// Extract form data
form := ContactForm{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Phone: strings.TrimSpace(r.FormValue("phone")),
Computer: strings.TrimSpace(r.FormValue("computer")),
Service: strings.TrimSpace(r.FormValue("service")),
Message: strings.TrimSpace(r.FormValue("message")),
}
// Store form data for re-rendering on error
data.FormData = form
// Validate required fields
if form.Name == "" || form.Email == "" || form.Message == "" {
data.ErrorMessage = "Vul alle verplichte velden in (naam, e-mail, bericht)."
s.renderTemplate(w, "contact.html", *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)
if err != nil {
log.Printf("Error sending message to Telegram: %v", err)
data.ErrorMessage = "Er is een fout opgetreden bij het versturen van uw bericht. Probeer het later opnieuw."
s.renderTemplate(w, "contact.html", *data)
return
}
}
// Update last submission time on success
mu.Lock()
lastSubmissionTime[ip] = time.Now()
mu.Unlock()
// On success, render success message
data.SuccessMessage = "Bedankt voor uw bericht! Ik neem zo snel mogelijk contact met u op."
data.FormData = ContactForm{} // Clear form 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
message := fmt.Sprintf(
"🔔 Nieuw contactformulier\n\n"+
"👤 *Naam:* %s\n"+
"📧 *Email:* %s\n"+
"📞 *Telefoon:* %s\n"+
"💻 *Computer:* %s\n"+
"🛠️ *Service:* %s\n\n"+
"💬 *Bericht:*\n%s",
escapeMarkdown(form.Name),
escapeMarkdown(form.Email),
escapeMarkdown(form.Phone),
escapeMarkdown(form.Computer),
escapeMarkdown(form.Service),
escapeMarkdown(form.Message),
)
// Prepare Telegram API request
telegramURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.config.TelegramBotToken)
payload := map[string]interface{}{
"chat_id": s.config.TelegramChatID,
"text": message,
"parse_mode": "MarkdownV2",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal JSON: %v", err)
}
// Send HTTP request
resp, err := http.Post(telegramURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to send HTTP request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Telegram API error: %s, Status Code: %d", string(bodyBytes), resp.StatusCode)
}
return nil
}
// escapeMarkdown escapes special characters for Telegram Markdown
func escapeMarkdown(text string) string {
if text == "" {
return "N/A"
}
// Escape special Markdown characters
replacer := strings.NewReplacer(
"*", "\\*",
"_", "\\_",
"`", "\\`",
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"~", "\\~",
">", "\\>",
"#", "\\#",
"+", "\\+",
"-", "\\-",
"=", "\\=",
"|", "\\|",
"{", "\\{",
"}", "\\}",
".", "\\.",
"!", "\\!",
)
return replacer.Replace(text)
}
// getClientIP extracts the client IP address from the request
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header first (for reverse proxies)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For can contain multiple IPs, take the first one
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
// Fall back to RemoteAddr
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
return r.RemoteAddr[:idx]
}
return r.RemoteAddr
}
// healthHandler provides a simple health check endpoint
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"healthy","service":"linuxservice"}`))
}
// aboutHandler handles the About Me page
func (s *Server) aboutHandler(w http.ResponseWriter, r *http.Request) {
data := s.createPageData("Over mij - "+s.config.CompanyName, "about")
// Populate dynamic About fields from config/env
data.AboutName = s.config.AboutName
now := time.Now()
// Parse BirthDate (ISO YYYY-MM-DD)
if t, err := time.Parse("2006-01-02", s.config.BirthDate); err == nil {
age := now.Year() - t.Year()
if now.Month() < t.Month() || (now.Month() == t.Month() && now.Day() < t.Day()) {
age--
}
data.AgeYears = age
}
// Parse SonBirthDate (ISO YYYY-MM-DD)
if t, err := time.Parse("2006-01-02", s.config.SonBirthDate); err == nil {
sonAge := now.Year() - t.Year()
if now.Month() < t.Month() || (now.Month() == t.Month() && now.Day() < t.Day()) {
sonAge--
}
data.SonAgeYears = sonAge
}
s.renderTemplate(w, "over-mij.html", data)
}
// dienstenHandler handles the services page
func (s *Server) dienstenHandler(w http.ResponseWriter, r *http.Request) {
data := s.createPageData("Diensten en tarieven - "+s.config.CompanyName, "diensten")
s.renderTemplate(w, "diensten.html", data)
}
// linuxHandler handles the Linux page (distributions + features)
func (s *Server) linuxHandler(w http.ResponseWriter, r *http.Request) {
data := s.createPageData("Linux distributies en functies - "+s.config.CompanyName, "linux")
s.renderTemplate(w, "linux.html", data)
}
// sitemapHandler serves the XML sitemap
func (s *Server) sitemapHandler(w http.ResponseWriter, r *http.Request) {
baseURL := "https://" + s.config.Domain
pages := []struct {
Path string
Priority string
ChangeFreq string
}{
{"/", "1.0", "weekly"},
{"/diensten", "0.9", "monthly"},
{"/linux", "0.8", "monthly"},
{"/over-mij", "0.7", "monthly"},
{"/contact", "0.5", "yearly"},
}
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
sb.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` + "\n")
for _, p := range pages {
sb.WriteString(" <url>\n")
fmt.Fprintf(&sb, " <loc>%s%s</loc>\n", baseURL, p.Path)
fmt.Fprintf(&sb, " <changefreq>%s</changefreq>\n", p.ChangeFreq)
fmt.Fprintf(&sb, " <priority>%s</priority>\n", p.Priority)
sb.WriteString(" </url>\n")
}
sb.WriteString(`</urlset>` + "\n")
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Write([]byte(sb.String()))
}
// setupRoutes configures all HTTP routes
func (s *Server) setupRoutes() {
// Static files
fs := http.FileServer(http.Dir("static/"))
http.Handle("/static/", http.StripPrefix("/static/", cacheControlMiddleware(fs)))
// Promo files under /promo served from static/promo/
promoFS := http.FileServer(http.Dir("static/promo/"))
http.Handle("/promo/", http.StripPrefix("/promo/", cacheControlMiddleware(promoFS)))
// Page routes
http.HandleFunc("/", s.homeHandler)
http.HandleFunc("/contact", s.contactHandler)
http.HandleFunc("/over-mij", s.aboutHandler)
http.HandleFunc("/diensten", s.dienstenHandler)
http.HandleFunc("/linux", s.linuxHandler)
http.HandleFunc("/sitemap.xml", s.sitemapHandler)
http.HandleFunc("/health", s.healthHandler)
}
func main() {
server := NewServer()
server.setupRoutes()
// Start background cleanup for rate limiting map
go cleanupOldSubmissions()
log.Printf("Server starting on %s", server.config.Port)
log.Fatal(http.ListenAndServe(server.config.Port, nil))
}
// cacheControlMiddleware sets Cache-Control headers for static assets
func cacheControlMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") ||
strings.HasSuffix(path, ".png") || strings.HasSuffix(path, ".jpg") ||
strings.HasSuffix(path, ".jpeg") || strings.HasSuffix(path, ".webp") ||
strings.HasSuffix(path, ".svg") || strings.HasSuffix(path, ".ico") ||
strings.HasSuffix(path, ".woff2"):
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
default:
w.Header().Set("Cache-Control", "public, max-age=300")
}
next.ServeHTTP(w, r)
})
}