1086 lines
33 KiB
Go
1086 lines
33 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/smtp"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Activity struct {
|
|
Name string `json:"name"`
|
|
Icon string `json:"icon"`
|
|
Type string `json:"type"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
CustomDescription string `json:"customDescription,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type AvailableDate struct {
|
|
Date string `json:"date"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type AppSettings struct {
|
|
Names []string `json:"names"`
|
|
AdminPassword string `json:"adminPassword"`
|
|
Activities []Activity `json:"-"`
|
|
AvailableDates []AvailableDate `json:"-"`
|
|
}
|
|
|
|
type Booking struct {
|
|
BookedAt string `json:"bookedAt"`
|
|
Date string `json:"date"`
|
|
DateType string `json:"dateType"`
|
|
Name string `json:"name"`
|
|
Activities []BookingActivity `json:"activities"`
|
|
}
|
|
|
|
type BookingActivity struct {
|
|
Name string `json:"name"`
|
|
Icon string `json:"icon"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type PublicSettings struct {
|
|
Activities []Activity `json:"activities"`
|
|
AvailableDates []AvailableDate `json:"availableDates"`
|
|
Names []string `json:"names"`
|
|
}
|
|
|
|
type passwordRequest struct {
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
var settingsMu sync.Mutex
|
|
var bookingsMu sync.Mutex
|
|
var sessionsMu sync.Mutex
|
|
var adminSessions = make(map[string]time.Time)
|
|
|
|
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)
|
|
http.HandleFunc("/api/booked-dates", handleBookedDates)
|
|
http.HandleFunc("/api/settings", handlePublicSettings)
|
|
http.HandleFunc("/api/admin/login", handleAdminLogin)
|
|
http.HandleFunc("/api/admin/logout", handleAdminLogout)
|
|
http.HandleFunc("/api/admin/dates", handleAdminDates)
|
|
http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates)
|
|
http.HandleFunc("/api/admin/activities", handleAdminActivities)
|
|
http.HandleFunc("/api/admin/bookings", handleAdminBookings)
|
|
http.HandleFunc("/admin", handleAdminPage)
|
|
|
|
// 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 handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/admin" && r.URL.Path != "/admin/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.ServeFile(w, r, "public/admin.html")
|
|
}
|
|
|
|
func handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
settings, err := readSettings()
|
|
if err != nil {
|
|
log.Printf("Error reading app settings: %v", err)
|
|
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, PublicSettings{
|
|
Activities: enabledActivities(settings.Activities),
|
|
AvailableDates: availableDatesFromToday(settings.AvailableDates),
|
|
Names: settings.Names,
|
|
})
|
|
}
|
|
|
|
func handleAdminActivities(w http.ResponseWriter, r *http.Request) {
|
|
if !isAdminRequest(r) {
|
|
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
settings, err := readSettings()
|
|
if err != nil {
|
|
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, settings.Activities)
|
|
case http.MethodPost:
|
|
var activity Activity
|
|
if err := json.NewDecoder(r.Body).Decode(&activity); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Ungültige Aktivität."})
|
|
return
|
|
}
|
|
if err := validateActivity(activity); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: err.Error()})
|
|
return
|
|
}
|
|
if err := updateActivities(func(activities []Activity) ([]Activity, error) {
|
|
for _, existing := range activities {
|
|
if strings.EqualFold(existing.Name, activity.Name) {
|
|
return nil, fmt.Errorf("activity already exists")
|
|
}
|
|
}
|
|
return append(activities, activity), nil
|
|
}); err != nil {
|
|
if err.Error() == "activity already exists" {
|
|
writeJSON(w, http.StatusConflict, Response{Success: false, Message: "Diese Aktivität existiert bereits."})
|
|
return
|
|
}
|
|
http.Error(w, "Activity could not be saved", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, Response{Success: true, Message: "Aktivität hinzugefügt."})
|
|
case http.MethodPut:
|
|
var request struct {
|
|
OriginalName string `json:"originalName"`
|
|
Activity
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.OriginalName == "" {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Ungültige Aktivität."})
|
|
return
|
|
}
|
|
if err := validateActivity(request.Activity); err != nil && request.Type != "custom" {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: err.Error()})
|
|
return
|
|
}
|
|
if request.Type == "custom" && (strings.TrimSpace(request.Name) == "" || len([]rune(request.Name)) > 100) {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte gib einen Namen mit maximal 100 Zeichen ein."})
|
|
return
|
|
}
|
|
updated := false
|
|
if err := updateActivities(func(activities []Activity) ([]Activity, error) {
|
|
for index := range activities {
|
|
if activities[index].Name == request.OriginalName {
|
|
if activities[index].Type == "custom" {
|
|
if request.Icon != activities[index].Icon || request.Type != "custom" {
|
|
return nil, fmt.Errorf("custom activity fields are read-only")
|
|
}
|
|
for otherIndex, other := range activities {
|
|
if otherIndex != index && strings.EqualFold(other.Name, request.Name) {
|
|
return nil, fmt.Errorf("activity already exists")
|
|
}
|
|
}
|
|
activities[index].Name = request.Name
|
|
activities[index].Disabled = request.Disabled
|
|
updated = true
|
|
break
|
|
}
|
|
for otherIndex, other := range activities {
|
|
if otherIndex != index && strings.EqualFold(other.Name, request.Name) {
|
|
return nil, fmt.Errorf("activity already exists")
|
|
}
|
|
}
|
|
activities[index] = request.Activity
|
|
updated = true
|
|
break
|
|
}
|
|
}
|
|
if !updated {
|
|
return nil, fmt.Errorf("activity does not exist")
|
|
}
|
|
return activities, nil
|
|
}); err != nil {
|
|
switch err.Error() {
|
|
case "activity already exists":
|
|
writeJSON(w, http.StatusConflict, Response{Success: false, Message: "Diese Aktivität existiert bereits."})
|
|
case "activity does not exist":
|
|
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Aktivität wurde nicht gefunden."})
|
|
case "custom activity fields are read-only":
|
|
writeJSON(w, http.StatusForbidden, Response{Success: false, Message: "Typ und Icon dieser Aktivität können nicht geändert werden."})
|
|
default:
|
|
http.Error(w, "Activity could not be saved", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Aktivität aktualisiert."})
|
|
case http.MethodDelete:
|
|
var request struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Name == "" {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Aktivität wurde nicht angegeben."})
|
|
return
|
|
}
|
|
deleted := false
|
|
if err := updateActivities(func(activities []Activity) ([]Activity, error) {
|
|
filtered := make([]Activity, 0, len(activities))
|
|
for _, activity := range activities {
|
|
if activity.Name == request.Name {
|
|
if activity.Type == "custom" {
|
|
return nil, fmt.Errorf("activity cannot be deleted")
|
|
}
|
|
deleted = true
|
|
continue
|
|
}
|
|
filtered = append(filtered, activity)
|
|
}
|
|
if !deleted {
|
|
return nil, fmt.Errorf("activity does not exist")
|
|
}
|
|
return filtered, nil
|
|
}); err != nil {
|
|
if err.Error() == "activity does not exist" {
|
|
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Aktivität wurde nicht gefunden."})
|
|
return
|
|
}
|
|
if err.Error() == "activity cannot be deleted" {
|
|
writeJSON(w, http.StatusForbidden, Response{Success: false, Message: "Diese Aktivität kann nicht gelöscht werden."})
|
|
return
|
|
}
|
|
http.Error(w, "Activity could not be saved", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Aktivität gelöscht."})
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func handleAdminBookings(w http.ResponseWriter, r *http.Request) {
|
|
if !isAdminRequest(r) {
|
|
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
bookings, err := getBookings()
|
|
if err != nil {
|
|
log.Printf("Error reading bookings: %v", err)
|
|
http.Error(w, "Bookings could not be loaded", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sort.SliceStable(bookings, func(first, second int) bool {
|
|
return bookings[first].BookedAt > bookings[second].BookedAt
|
|
})
|
|
writeJSON(w, http.StatusOK, bookings)
|
|
case http.MethodDelete:
|
|
var request struct {
|
|
Date string `json:"date"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || strings.TrimSpace(request.Date) == "" {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte gib ein Datum an."})
|
|
return
|
|
}
|
|
request.Date = strings.TrimSpace(request.Date)
|
|
|
|
bookingsMu.Lock()
|
|
defer bookingsMu.Unlock()
|
|
bookings, err := readBookings()
|
|
if err != nil {
|
|
log.Printf("Error reading bookings: %v", err)
|
|
http.Error(w, "Bookings could not be loaded", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
filtered := make([]Booking, 0, len(bookings))
|
|
deleted := false
|
|
for _, booking := range bookings {
|
|
if booking.Date == request.Date {
|
|
deleted = true
|
|
continue
|
|
}
|
|
filtered = append(filtered, booking)
|
|
}
|
|
if !deleted {
|
|
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Gebuchter Termin wurde nicht gefunden."})
|
|
return
|
|
}
|
|
if err := writeJSONFile(getBookedDatesFilePath(), filtered); err != nil {
|
|
log.Printf("Error deleting booking: %v", err)
|
|
http.Error(w, "Booking could not be deleted", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Gebuchter Termin gelöscht."})
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var request passwordRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
settings, err := readSettings()
|
|
if err != nil {
|
|
log.Printf("Error reading app settings: %v", err)
|
|
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if settings.AdminPassword == "" || subtle.ConstantTimeCompare([]byte(request.Password), []byte(settings.AdminPassword)) != 1 {
|
|
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Ungültiges Passwort."})
|
|
return
|
|
}
|
|
|
|
sessionID, err := createAdminSession()
|
|
if err != nil {
|
|
log.Printf("Error creating admin session: %v", err)
|
|
http.Error(w, "Session could not be created", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "date_wizard_admin",
|
|
Value: sessionID,
|
|
Path: "/",
|
|
MaxAge: int((8 * time.Hour).Seconds()),
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Secure: r.TLS != nil,
|
|
})
|
|
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Angemeldet."})
|
|
}
|
|
|
|
func handleAdminLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if cookie, err := r.Cookie("date_wizard_admin"); err == nil {
|
|
sessionsMu.Lock()
|
|
delete(adminSessions, cookie.Value)
|
|
sessionsMu.Unlock()
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "date_wizard_admin", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil})
|
|
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Abgemeldet."})
|
|
}
|
|
|
|
func handleAdminDates(w http.ResponseWriter, r *http.Request) {
|
|
if !isAdminRequest(r) {
|
|
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
settings, err := readSettings()
|
|
if err != nil {
|
|
log.Printf("Error reading app settings: %v", err)
|
|
http.Error(w, "Settings could not be loaded", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, availableDatesFromToday(settings.AvailableDates))
|
|
case http.MethodPost:
|
|
var availableDate AvailableDate
|
|
if err := json.NewDecoder(r.Body).Decode(&availableDate); err != nil || !isValidAvailableDate(availableDate) || !isTodayOrFuture(availableDate.Date) {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle ein gültiges Datum ab heute und einen Tagestyp."})
|
|
return
|
|
}
|
|
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
|
|
for _, date := range dates {
|
|
if date.Date == availableDate.Date {
|
|
return nil, fmt.Errorf("date already exists")
|
|
}
|
|
}
|
|
return append(dates, availableDate), nil
|
|
}); err != nil {
|
|
if err.Error() == "date already exists" {
|
|
writeJSON(w, http.StatusConflict, Response{Success: false, Message: "Dieses Datum ist bereits verfügbar."})
|
|
return
|
|
}
|
|
log.Printf("Error adding available date: %v", err)
|
|
http.Error(w, "Date could not be saved", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, Response{Success: true, Message: "Datum hinzugefügt."})
|
|
case http.MethodPut:
|
|
var availableDate AvailableDate
|
|
if err := json.NewDecoder(r.Body).Decode(&availableDate); err != nil || !isValidAvailableDate(availableDate) || !isTodayOrFuture(availableDate.Date) {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle ein Datum ab heute und einen gültigen Tagestyp."})
|
|
return
|
|
}
|
|
updated := false
|
|
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
|
|
for index := range dates {
|
|
if dates[index].Date == availableDate.Date {
|
|
dates[index].Type = availableDate.Type
|
|
updated = true
|
|
break
|
|
}
|
|
}
|
|
if !updated {
|
|
return nil, fmt.Errorf("date does not exist")
|
|
}
|
|
return dates, nil
|
|
}); err != nil {
|
|
if err.Error() == "date does not exist" {
|
|
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Datum wurde nicht gefunden."})
|
|
return
|
|
}
|
|
log.Printf("Error updating available date: %v", err)
|
|
http.Error(w, "Date could not be saved", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Tagestyp aktualisiert."})
|
|
case http.MethodDelete:
|
|
var availableDate AvailableDate
|
|
if err := json.NewDecoder(r.Body).Decode(&availableDate); err != nil || !isValidAvailableDate(AvailableDate{Date: availableDate.Date, Type: "short"}) || !isTodayOrFuture(availableDate.Date) {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle ein Datum ab heute."})
|
|
return
|
|
}
|
|
deleted := false
|
|
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
|
|
filtered := make([]AvailableDate, 0, len(dates))
|
|
for _, date := range dates {
|
|
if date.Date == availableDate.Date {
|
|
deleted = true
|
|
continue
|
|
}
|
|
filtered = append(filtered, date)
|
|
}
|
|
if !deleted {
|
|
return nil, fmt.Errorf("date does not exist")
|
|
}
|
|
return filtered, nil
|
|
}); err != nil {
|
|
if err.Error() == "date does not exist" {
|
|
writeJSON(w, http.StatusNotFound, Response{Success: false, Message: "Datum wurde nicht gefunden."})
|
|
return
|
|
}
|
|
log.Printf("Error deleting available date: %v", err)
|
|
http.Error(w, "Date could not be saved", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Datum gelöscht."})
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func handleAdminBulkDates(w http.ResponseWriter, r *http.Request) {
|
|
if !isAdminRequest(r) {
|
|
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var requestedDates []AvailableDate
|
|
if err := json.NewDecoder(r.Body).Decode(&requestedDates); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Ungültige Terminauswahl."})
|
|
return
|
|
}
|
|
seen := make(map[string]bool, len(requestedDates))
|
|
for _, date := range requestedDates {
|
|
if !isValidAvailableDate(date) || !isTodayOrFuture(date.Date) || seen[date.Date] {
|
|
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle gültige, eindeutige Daten ab heute."})
|
|
return
|
|
}
|
|
seen[date.Date] = true
|
|
}
|
|
bookedDates, err := getBookedDates()
|
|
if err != nil {
|
|
log.Printf("Error reading booked dates: %v", err)
|
|
http.Error(w, "Booked dates could not be loaded", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
bookedDateSet := make(map[string]bool, len(bookedDates))
|
|
for _, date := range bookedDates {
|
|
bookedDateSet[date] = true
|
|
}
|
|
|
|
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
|
|
preserved := make([]AvailableDate, 0, len(dates)+len(requestedDates))
|
|
for _, date := range dates {
|
|
if !isTodayOrFuture(date.Date) || bookedDateSet[date.Date] {
|
|
preserved = append(preserved, date)
|
|
}
|
|
}
|
|
for _, date := range requestedDates {
|
|
if !bookedDateSet[date.Date] {
|
|
preserved = append(preserved, date)
|
|
}
|
|
}
|
|
return preserved, nil
|
|
}); err != nil {
|
|
log.Printf("Error saving available dates: %v", err)
|
|
http.Error(w, "Dates could not be saved", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Änderungen gespeichert."})
|
|
}
|
|
|
|
func getSettingsFilePath() string {
|
|
return filepath.Join("config", "appsettings.json")
|
|
}
|
|
|
|
func readSettings() (AppSettings, error) {
|
|
settingsMu.Lock()
|
|
defer settingsMu.Unlock()
|
|
return readSettingsFile()
|
|
}
|
|
|
|
func readSettingsFile() (AppSettings, error) {
|
|
var settings AppSettings
|
|
data, err := os.ReadFile(getSettingsFilePath())
|
|
if err != nil {
|
|
return settings, err
|
|
}
|
|
if err := json.Unmarshal(data, &settings); err != nil {
|
|
return settings, err
|
|
}
|
|
var legacy struct {
|
|
Activities []Activity `json:"activities"`
|
|
AvailableDates []AvailableDate `json:"availableDates"`
|
|
}
|
|
if err := json.Unmarshal(data, &legacy); err != nil {
|
|
return settings, err
|
|
}
|
|
settings.Activities = legacy.Activities
|
|
settings.AvailableDates = legacy.AvailableDates
|
|
if activities, found, err := readOptionalJSON(getActivitiesFilePath(), &[]Activity{}); err != nil {
|
|
return settings, err
|
|
} else if found {
|
|
settings.Activities = *activities.(*[]Activity)
|
|
}
|
|
if dates, found, err := readOptionalJSON(getAvailableDatesFilePath(), &[]AvailableDate{}); err != nil {
|
|
return settings, err
|
|
} else if found {
|
|
settings.AvailableDates = *dates.(*[]AvailableDate)
|
|
}
|
|
return settings, nil
|
|
}
|
|
|
|
func readOptionalJSON(path string, target interface{}) (interface{}, bool, error) {
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return target, false, nil
|
|
}
|
|
if err != nil {
|
|
return target, false, err
|
|
}
|
|
if err := json.Unmarshal(data, target); err != nil {
|
|
return target, false, err
|
|
}
|
|
return target, true, nil
|
|
}
|
|
|
|
func updateAvailableDates(update func([]AvailableDate) ([]AvailableDate, error)) error {
|
|
settingsMu.Lock()
|
|
defer settingsMu.Unlock()
|
|
|
|
settings, err := readSettingsFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dates, err := update(settings.AvailableDates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sort.Slice(dates, func(i, j int) bool { return dates[i].Date < dates[j].Date })
|
|
settings.AvailableDates = dates
|
|
|
|
return writeJSONFile(getAvailableDatesFilePath(), dates)
|
|
}
|
|
|
|
func updateActivities(update func([]Activity) ([]Activity, error)) error {
|
|
settingsMu.Lock()
|
|
defer settingsMu.Unlock()
|
|
|
|
settings, err := readSettingsFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
activities, err := update(settings.Activities)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
settings.Activities = activities
|
|
|
|
return writeJSONFile(getActivitiesFilePath(), activities)
|
|
}
|
|
|
|
func writeJSONFile(path string, value interface{}) error {
|
|
data, err := json.MarshalIndent(value, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temporaryPath := path + ".tmp"
|
|
if err := os.WriteFile(temporaryPath, data, 0600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(temporaryPath, path)
|
|
}
|
|
|
|
func getActivitiesFilePath() string {
|
|
return filepath.Join("config", "activities.json")
|
|
}
|
|
|
|
func getAvailableDatesFilePath() string {
|
|
return filepath.Join("config", "dates_available.json")
|
|
}
|
|
|
|
func validateActivity(activity Activity) error {
|
|
if strings.TrimSpace(activity.Name) == "" || len([]rune(activity.Name)) > 100 {
|
|
return fmt.Errorf("Bitte gib einen Namen mit maximal 100 Zeichen ein.")
|
|
}
|
|
if strings.TrimSpace(activity.Icon) == "" || len([]rune(activity.Icon)) > 8 {
|
|
return fmt.Errorf("Bitte wähle ein Icon aus.")
|
|
}
|
|
if activity.Type != "short" && activity.Type != "long" {
|
|
return fmt.Errorf("Bitte wähle Kurz oder Lang.")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func enabledActivities(activities []Activity) []Activity {
|
|
enabled := make([]Activity, 0, len(activities))
|
|
var customActivity *Activity
|
|
for _, activity := range activities {
|
|
if activity.Type == "custom" {
|
|
if !activity.Disabled {
|
|
custom := activity
|
|
customActivity = &custom
|
|
}
|
|
continue
|
|
}
|
|
if !activity.Disabled {
|
|
enabled = append(enabled, activity)
|
|
}
|
|
}
|
|
if customActivity != nil {
|
|
enabled = append(enabled, *customActivity)
|
|
}
|
|
return enabled
|
|
}
|
|
|
|
func isValidAvailableDate(availableDate AvailableDate) bool {
|
|
if availableDate.Type != "short" && availableDate.Type != "long" {
|
|
return false
|
|
}
|
|
parsedDate, err := time.Parse("2006-01-02", availableDate.Date)
|
|
return err == nil && parsedDate.Format("2006-01-02") == availableDate.Date
|
|
}
|
|
|
|
func isTodayOrFuture(date string) bool {
|
|
return date >= time.Now().Format("2006-01-02")
|
|
}
|
|
|
|
func availableDatesFromToday(dates []AvailableDate) []AvailableDate {
|
|
upcomingDates := make([]AvailableDate, 0, len(dates))
|
|
for _, availableDate := range dates {
|
|
if isTodayOrFuture(availableDate.Date) {
|
|
upcomingDates = append(upcomingDates, availableDate)
|
|
}
|
|
}
|
|
return upcomingDates
|
|
}
|
|
|
|
func createAdminSession() (string, error) {
|
|
bytes := make([]byte, 32)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
sessionID := fmt.Sprintf("%x", bytes)
|
|
sessionsMu.Lock()
|
|
adminSessions[sessionID] = time.Now().Add(8 * time.Hour)
|
|
sessionsMu.Unlock()
|
|
return sessionID, nil
|
|
}
|
|
|
|
func isAdminRequest(r *http.Request) bool {
|
|
cookie, err := r.Cookie("date_wizard_admin")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
sessionsMu.Lock()
|
|
defer sessionsMu.Unlock()
|
|
expiresAt, found := adminSessions[cookie.Value]
|
|
if !found || time.Now().After(expiresAt) {
|
|
delete(adminSessions, cookie.Value)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, value interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(value)
|
|
}
|
|
|
|
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)
|
|
|
|
if err := recordBooking(payload); err != nil {
|
|
log.Printf("Error recording booked date: %v", err)
|
|
}
|
|
|
|
err = sendEmail(payload)
|
|
if err != nil {
|
|
log.Printf("Warning: Email could not be sent: %v", err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(Response{
|
|
Success: true,
|
|
Message: "Reservierung erfolgreich übermittelt!",
|
|
})
|
|
}
|
|
|
|
func handleBookedDates(w http.ResponseWriter, r *http.Request) {
|
|
if enableCORS(w, r) {
|
|
return
|
|
}
|
|
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
dates, err := getBookedDates()
|
|
if err != nil {
|
|
log.Printf("Error reading booked dates: %v", err)
|
|
dates = []string{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(dates)
|
|
}
|
|
|
|
func getBookedDatesFilePath() string {
|
|
return filepath.Join("config", "dates_booked.json")
|
|
}
|
|
|
|
func getBookedDates() ([]string, error) {
|
|
bookings, err := getBookings()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dates := make([]string, 0, len(bookings))
|
|
for _, booking := range bookings {
|
|
if trimmed := strings.TrimSpace(booking.Date); trimmed != "" {
|
|
dates = append(dates, trimmed)
|
|
}
|
|
}
|
|
return dates, nil
|
|
}
|
|
|
|
func getBookings() ([]Booking, error) {
|
|
bookingsMu.Lock()
|
|
defer bookingsMu.Unlock()
|
|
return readBookings()
|
|
}
|
|
|
|
func readBookings() ([]Booking, error) {
|
|
filePath := getBookedDatesFilePath()
|
|
data, err := os.ReadFile(filePath)
|
|
if os.IsNotExist(err) {
|
|
legacyPath := os.Getenv("LEGACY_BOOKED_DATES_FILE")
|
|
if legacyPath == "" {
|
|
legacyPath = filepath.Join("config", "booked_dates.json")
|
|
}
|
|
data, err = os.ReadFile(legacyPath)
|
|
if os.IsNotExist(err) {
|
|
return []Booking{}, nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var bookings []Booking
|
|
if err := json.Unmarshal(data, &bookings); err == nil {
|
|
return bookings, nil
|
|
}
|
|
|
|
var legacyDates []string
|
|
if err := json.Unmarshal(data, &legacyDates); err != nil {
|
|
return nil, err
|
|
}
|
|
bookings = make([]Booking, 0, len(legacyDates))
|
|
for _, date := range legacyDates {
|
|
if trimmed := strings.TrimSpace(date); trimmed != "" {
|
|
bookings = append(bookings, Booking{Date: trimmed})
|
|
}
|
|
}
|
|
return bookings, nil
|
|
}
|
|
|
|
func recordBooking(payload SurveyPayload) error {
|
|
date := strings.TrimSpace(payload.Date)
|
|
if date == "" {
|
|
return nil
|
|
}
|
|
|
|
bookingsMu.Lock()
|
|
defer bookingsMu.Unlock()
|
|
bookings, err := readBookings()
|
|
if err != nil {
|
|
bookings = []Booking{}
|
|
}
|
|
for _, booking := range bookings {
|
|
if booking.Date == date {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
bookingActivities := make([]BookingActivity, 0, len(payload.Activities))
|
|
for _, activity := range payload.Activities {
|
|
bookingActivities = append(bookingActivities, BookingActivity{
|
|
Name: activity.Name,
|
|
Icon: activity.Icon,
|
|
Type: activity.Type,
|
|
})
|
|
}
|
|
|
|
bookings = append(bookings, Booking{
|
|
BookedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Date: date,
|
|
DateType: payload.DateType,
|
|
Name: payload.Name,
|
|
Activities: bookingActivities,
|
|
})
|
|
return writeJSONFile(getBookedDatesFilePath(), bookings)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
boundary := fmt.Sprintf("BOUNDARY_%d", time.Now().UnixNano())
|
|
|
|
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(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", boundary))
|
|
|
|
// Text-Teil der E-Mail
|
|
bodyBuilder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
|
bodyBuilder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
|
bodyBuilder.WriteString("Content-Transfer-Encoding: 8bit\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", cleanActivityName(payload.Activity)))
|
|
|
|
if len(payload.Activities) > 0 {
|
|
bodyBuilder.WriteString("Details der Aktivitäten:\r\n")
|
|
for _, act := range payload.Activities {
|
|
actName := cleanActivityName(act.Name)
|
|
if act.CustomDescription != "" {
|
|
cleanDesc := cleanActivityName(act.CustomDescription)
|
|
if !strings.Contains(actName, cleanDesc) {
|
|
actName = cleanDesc
|
|
}
|
|
}
|
|
bodyBuilder.WriteString(fmt.Sprintf(" - %s %s (%s)\r\n", act.Icon, actName, act.Type))
|
|
}
|
|
bodyBuilder.WriteString("\r\n")
|
|
}
|
|
|
|
bodyBuilder.WriteString("Im Anhang findest du einen Kalendereintrag (.ics), den du direkt auf deinem Smartphone/Kalender importieren kannst. 💕\r\n\r\n")
|
|
|
|
// iCalendar-Anhang (.ics)
|
|
icsData := generateICS(payload)
|
|
bodyBuilder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
|
bodyBuilder.WriteString("Content-Type: text/calendar; charset=UTF-8; method=REQUEST; name=\"date-event.ics\"\r\n")
|
|
bodyBuilder.WriteString("Content-Disposition: attachment; filename=\"date-event.ics\"\r\n")
|
|
bodyBuilder.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
|
|
bodyBuilder.WriteString(icsData)
|
|
bodyBuilder.WriteString("\r\n")
|
|
|
|
// Abschluss-Boundary
|
|
bodyBuilder.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
|
|
|
auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost)
|
|
addr := fmt.Sprintf("%s:%s", smtpHost, smtpPort)
|
|
|
|
return smtp.SendMail(addr, auth, fromEmail, []string{toEmail}, bodyBuilder.Bytes())
|
|
}
|
|
|
|
func cleanActivityName(s string) string {
|
|
s = strings.TrimPrefix(s, "Etwas eigenes: ")
|
|
s = strings.TrimPrefix(s, "Etwas eigenes:")
|
|
s = strings.TrimPrefix(s, "Sonstiges: ")
|
|
s = strings.TrimPrefix(s, "Sonstiges:")
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func generateICS(payload SurveyPayload) string {
|
|
parsedDate, err := time.Parse("2006-01-02", payload.Date)
|
|
dtStart := strings.ReplaceAll(payload.Date, "-", "")
|
|
dtEnd := dtStart
|
|
if err == nil {
|
|
dtStart = parsedDate.Format("20060102")
|
|
dtEnd = parsedDate.AddDate(0, 0, 1).Format("20060102")
|
|
}
|
|
|
|
dtStamp := time.Now().UTC().Format("20060102T150405Z")
|
|
uid := fmt.Sprintf("date-wizard-%s-%d@date-wizard", dtStart, time.Now().UnixNano())
|
|
|
|
activityText := cleanActivityName(payload.Activity)
|
|
for _, act := range payload.Activities {
|
|
if act.CustomDescription != "" {
|
|
cleanDesc := cleanActivityName(act.CustomDescription)
|
|
if !strings.Contains(activityText, cleanDesc) {
|
|
activityText = cleanDesc
|
|
}
|
|
}
|
|
}
|
|
|
|
summary := fmt.Sprintf("Date: %s", activityText)
|
|
description := fmt.Sprintf("Date mit %s\\nAktivität(en): %s", payload.Name, activityText)
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("BEGIN:VCALENDAR\r\n")
|
|
sb.WriteString("VERSION:2.0\r\n")
|
|
sb.WriteString("PRODID:-//Date-Wizard//DE\r\n")
|
|
sb.WriteString("CALSCALE:GREGORIAN\r\n")
|
|
sb.WriteString("METHOD:REQUEST\r\n")
|
|
sb.WriteString("BEGIN:VEVENT\r\n")
|
|
sb.WriteString(fmt.Sprintf("UID:%s\r\n", uid))
|
|
sb.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", dtStamp))
|
|
sb.WriteString(fmt.Sprintf("DTSTART;VALUE=DATE:%s\r\n", dtStart))
|
|
sb.WriteString(fmt.Sprintf("DTEND;VALUE=DATE:%s\r\n", dtEnd))
|
|
sb.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", summary))
|
|
sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", description))
|
|
sb.WriteString("STATUS:CONFIRMED\r\n")
|
|
sb.WriteString("END:VEVENT\r\n")
|
|
sb.WriteString("END:VCALENDAR\r\n")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|