From 0ee82eb6730aad0b5d5bd7d927928488bd837fc8 Mon Sep 17 00:00:00 2001 From: troogs Date: Wed, 12 Aug 2026 17:20:03 +0200 Subject: [PATCH] feat: add Go microservice, Dockerfile, docker-compose, and API submission endpoint --- .dockerignore | 6 + .env.example | 13 ++ .../7-create-docker-microservice.prompt.md | 20 ++ Dockerfile | 24 +++ docker-compose.yml | 20 ++ go.mod | 3 + main.go | 186 ++++++++++++++++++ activity.css => public/activity.css | 0 activity.html => public/activity.html | 0 activity.js => public/activity.js | 0 appsettings.json => public/appsettings.json | 0 ask.css => public/ask.css | 0 ask.js => public/ask.js | 0 checkout.css => public/checkout.css | 0 checkout.html => public/checkout.html | 0 checkout.js => public/checkout.js | 50 ++++- datetime.css => public/datetime.css | 0 datetime.html => public/datetime.html | 0 datetime.js => public/datetime.js | 0 index.html => public/index.html | 0 name.css => public/name.css | 0 name.html => public/name.html | 0 name.js => public/name.js | 0 style.css => public/style.css | 0 24 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/prompts/7-create-docker-microservice.prompt.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 main.go rename activity.css => public/activity.css (100%) rename activity.html => public/activity.html (100%) rename activity.js => public/activity.js (100%) rename appsettings.json => public/appsettings.json (100%) rename ask.css => public/ask.css (100%) rename ask.js => public/ask.js (100%) rename checkout.css => public/checkout.css (100%) rename checkout.html => public/checkout.html (100%) rename checkout.js => public/checkout.js (58%) rename datetime.css => public/datetime.css (100%) rename datetime.html => public/datetime.html (100%) rename datetime.js => public/datetime.js (100%) rename index.html => public/index.html (100%) rename name.css => public/name.css (100%) rename name.html => public/name.html (100%) rename name.js => public/name.js (100%) rename style.css => public/style.css (100%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a584cea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.github +Dockerfile +docker-compose.yml +.env +*.exe diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..94d5579 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Server Port & Configuration +PORT=8080 +WEB_DIR=public + +# SMTP Configuration +SMTP_HOST=mail.example.com +SMTP_PORT=587 +SMTP_USER=user@example.com +SMTP_PASS=secretpassword + +# Email Addresses +TO_EMAIL=destination@example.com +FROM_EMAIL=sender@example.com diff --git a/.github/prompts/7-create-docker-microservice.prompt.md b/.github/prompts/7-create-docker-microservice.prompt.md new file mode 100644 index 0000000..a48df41 --- /dev/null +++ b/.github/prompts/7-create-docker-microservice.prompt.md @@ -0,0 +1,20 @@ +I want to add backend functionality to send the survey responses as an email upon submission using Option 1 (a minimalist Go/Golang microservice). + +Please complete the following tasks: + +1. Go Microservice (`main.go`): + - Create a lightweight HTTP server using standard Go library packages (`net/http`, `net/smtp`, `encoding/json`). + - Create an endpoint (e.g., `POST /api/submit`) to accept JSON payload survey data from the front end. + - Configure SMTP credentials and server settings via environment variables (e.g., `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `TO_EMAIL`) via an .env file + - Format the incoming JSON survey data into a clean, readable text body and dispatch the email upon request. + - Handle CORS preflight (`OPTIONS`) and headers so the HTML/JS front end can POST to it smoothly. + +2. Dockerization (`Dockerfile`): + - Provide a multi-stage `Dockerfile` (e.g., compile with `golang:alpine`, copy to `scratch` or `alpine`) to ensure the resulting container image is ultra-lightweight (<20MB). + +3. Front-End Integration: + - Show how to update the existing JavaScript submission handler (using `fetch()`) to send the survey data as JSON to the new Go backend endpoint. + +Please keep the code minimalist, robust, and well-commented. + +when everything works, please create a simple docker-compose file that builds the project and runs the container. include traefik labels for reverse proxy routing. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5d092e0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# Stage 1: Build the Go binary +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o server . + +# Stage 2: Ultra-lightweight runtime container +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +# Copy binary and static front-end assets +COPY --from=builder /app/server /app/server +COPY --from=builder /app/public /app/public + +EXPOSE 8080 + +ENTRYPOINT ["/app/server"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c91a55c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + date-wizard: + build: + context: . + dockerfile: Dockerfile + container_name: date-wizard + restart: unless-stopped + ports: + - "8080:8080" + env_file: + - .env + labels: + - "traefik.enable=true" + - "traefik.http.routers.date-wizard.rule=Host(`date.example.com`)" + - "traefik.http.routers.date-wizard.entrypoints=websecure" + - "traefik.http.routers.date-wizard.tls=true" + - "traefik.http.routers.date-wizard.tls.certresolver=letsencrypt" + - "traefik.http.services.date-wizard.loadbalancer.server.port=8080" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df88d2d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module date-wizard + +go 1.22 diff --git a/main.go b/main.go new file mode 100644 index 0000000..40b1693 --- /dev/null +++ b/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "net/smtp" + "os" + "strings" +) + +type Activity struct { + Name string `json:"name"` + Icon string `json:"icon"` + Type string `json:"type"` +} + +type SurveyPayload struct { + Activity string `json:"activity"` + Activities []Activity `json:"activities"` + Date string `json:"date"` + DateType string `json:"dateType"` + Name string `json:"name"` +} + +type Response struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +func main() { + loadEnv(".env") + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + webDir := os.Getenv("WEB_DIR") + if webDir == "" { + webDir = "public" + } + + http.HandleFunc("/api/submit", handleSubmit) + + // Serve static files from webDir directory + fs := http.FileServer(http.Dir(webDir)) + http.Handle("/", fs) + + log.Printf("Server starting on port %s (serving static files from %s)...", port, webDir) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("Server failed to start: %v", err) + } +} + +func handleSubmit(w http.ResponseWriter, r *http.Request) { + if enableCORS(w, r) { + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var payload SurveyPayload + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(Response{ + Success: false, + Message: "Ungültige Anfragedaten.", + }) + return + } + + log.Printf("Received survey submission from %s for date %s (%s)", payload.Name, payload.Date, payload.Activity) + + err = sendEmail(payload) + if err != nil { + log.Printf("Error sending email: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(Response{ + Success: false, + Message: "E-Mail konnte nicht gesendet werden.", + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Response{ + Success: true, + Message: "Reservierung erfolgreich übermittelt!", + }) +} + +func enableCORS(w http.ResponseWriter, r *http.Request) bool { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return true + } + return false +} + +func sendEmail(payload SurveyPayload) error { + smtpHost := os.Getenv("SMTP_HOST") + smtpPort := os.Getenv("SMTP_PORT") + if smtpPort == "" { + smtpPort = "587" + } + smtpUser := os.Getenv("SMTP_USER") + smtpPass := os.Getenv("SMTP_PASS") + toEmail := os.Getenv("TO_EMAIL") + fromEmail := os.Getenv("FROM_EMAIL") + if fromEmail == "" { + fromEmail = smtpUser + } + + if smtpHost == "" || toEmail == "" { + log.Println("HINWEIS: SMTP_HOST oder TO_EMAIL nicht konfiguriert. E-Mail-Versand übersprungen.") + return nil + } + + var bodyBuilder bytes.Buffer + bodyBuilder.WriteString(fmt.Sprintf("From: %s\r\n", fromEmail)) + bodyBuilder.WriteString(fmt.Sprintf("To: %s\r\n", toEmail)) + bodyBuilder.WriteString(fmt.Sprintf("Subject: Neue Date-Reservierung von %s! 💕\r\n", payload.Name)) + bodyBuilder.WriteString("MIME-Version: 1.0\r\n") + bodyBuilder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n") + + bodyBuilder.WriteString("Hallo!\r\n\r\n") + bodyBuilder.WriteString("Es gibt eine neue Date-Reservierung über deinen Date-Planer:\r\n\r\n") + bodyBuilder.WriteString(fmt.Sprintf("👤 Name: %s\r\n", payload.Name)) + bodyBuilder.WriteString(fmt.Sprintf("📅 Datum: %s (%s)\r\n", payload.Date, payload.DateType)) + bodyBuilder.WriteString(fmt.Sprintf("💌 Aktivität(en): %s\r\n\r\n", payload.Activity)) + + if len(payload.Activities) > 0 { + bodyBuilder.WriteString("Details der Aktivitäten:\r\n") + for _, act := range payload.Activities { + bodyBuilder.WriteString(fmt.Sprintf(" - %s %s (%s)\r\n", act.Icon, act.Name, act.Type)) + } + bodyBuilder.WriteString("\r\n") + } + + bodyBuilder.WriteString("Viel Spaß beim gemeinsamen Date! 💕\r\n") + + auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost) + addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort) + + return smtp.SendMail(addr, auth, fromEmail, []string{toEmail}, bodyBuilder.Bytes()) +} + +func loadEnv(filename string) { + file, err := os.Open(filename) + if err != nil { + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, `"'`) + if os.Getenv(key) == "" { + os.Setenv(key, val) + } + } + } +} diff --git a/activity.css b/public/activity.css similarity index 100% rename from activity.css rename to public/activity.css diff --git a/activity.html b/public/activity.html similarity index 100% rename from activity.html rename to public/activity.html diff --git a/activity.js b/public/activity.js similarity index 100% rename from activity.js rename to public/activity.js diff --git a/appsettings.json b/public/appsettings.json similarity index 100% rename from appsettings.json rename to public/appsettings.json diff --git a/ask.css b/public/ask.css similarity index 100% rename from ask.css rename to public/ask.css diff --git a/ask.js b/public/ask.js similarity index 100% rename from ask.js rename to public/ask.js diff --git a/checkout.css b/public/checkout.css similarity index 100% rename from checkout.css rename to public/checkout.css diff --git a/checkout.html b/public/checkout.html similarity index 100% rename from checkout.html rename to public/checkout.html diff --git a/checkout.js b/public/checkout.js similarity index 58% rename from checkout.js rename to public/checkout.js index 7bff8f6..8906271 100644 --- a/checkout.js +++ b/public/checkout.js @@ -52,11 +52,49 @@ } checkoutBtn.addEventListener("click", () => { - // Später: API / Mail-Versand / Kalendereintrag ausführen - summarySection.classList.add("hidden"); - if (backLink) { - backLink.classList.add("hidden"); - } - confirmationSection.classList.remove("hidden"); + checkoutBtn.disabled = true; + const originalText = checkoutBtn.textContent; + checkoutBtn.textContent = "Wird übermittelt... 💌"; + + const payload = { + activity: selectedActivity, + activities: storedActivities, + date: selectedDate, + dateType: localStorage.getItem("selectedDateType") || "long", + name: selectedName, + }; + + fetch("/api/submit", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + .then((res) => { + if (!res.ok) { + throw new Error("Fehler beim Übermitteln."); + } + return res.json(); + }) + .then((data) => { + if (data.success) { + summarySection.classList.add("hidden"); + if (backLink) { + backLink.classList.add("hidden"); + } + confirmationSection.classList.remove("hidden"); + } else { + alert(data.message || "Es gab ein Problem bei der Übermittlung."); + checkoutBtn.disabled = false; + checkoutBtn.textContent = originalText; + } + }) + .catch((err) => { + console.error("Submission error:", err); + alert("Übermittlung fehlgeschlagen. Bitte versuche es erneut."); + checkoutBtn.disabled = false; + checkoutBtn.textContent = originalText; + }); }); })(); diff --git a/datetime.css b/public/datetime.css similarity index 100% rename from datetime.css rename to public/datetime.css diff --git a/datetime.html b/public/datetime.html similarity index 100% rename from datetime.html rename to public/datetime.html diff --git a/datetime.js b/public/datetime.js similarity index 100% rename from datetime.js rename to public/datetime.js diff --git a/index.html b/public/index.html similarity index 100% rename from index.html rename to public/index.html diff --git a/name.css b/public/name.css similarity index 100% rename from name.css rename to public/name.css diff --git a/name.html b/public/name.html similarity index 100% rename from name.html rename to public/name.html diff --git a/name.js b/public/name.js similarity index 100% rename from name.js rename to public/name.js diff --git a/style.css b/public/style.css similarity index 100% rename from style.css rename to public/style.css