Add protected date management admin

This commit is contained in:
2026-09-17 15:32:33 +02:00
parent 253b65d976
commit 097e72d364
14 changed files with 1100 additions and 67 deletions
+330
View File
@@ -3,13 +3,17 @@ package main
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/subtle"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"os"
"sort"
"strings"
"sync"
"time"
)
@@ -33,6 +37,32 @@ type Response struct {
Message string `json:"message"`
}
type AvailableDate struct {
Date string `json:"date"`
Type string `json:"type"`
}
type AppSettings struct {
Activities []Activity `json:"activities"`
AvailableDates []AvailableDate `json:"availableDates"`
Names []string `json:"names"`
AdminPassword string `json:"adminPassword"`
}
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 sessionsMu sync.Mutex
var adminSessions = make(map[string]time.Time)
func main() {
loadEnv(".env")
@@ -48,6 +78,11 @@ func main() {
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("/admin", handleAdminPage)
// Serve static files from webDir directory
fs := http.FileServer(http.Dir(webDir))
@@ -59,6 +94,301 @@ func main() {
}
}
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: settings.Activities,
AvailableDates: availableDatesFromToday(settings.AvailableDates),
Names: settings.Names,
})
}
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 getSettingsFilePath() string {
path := os.Getenv("SETTINGS_FILE")
if path == "" {
path = "appsettings.json"
}
return path
}
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
}
return settings, 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
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
temporaryPath := getSettingsFilePath() + ".tmp"
if err := os.WriteFile(temporaryPath, data, 0600); err != nil {
return err
}
return os.Rename(temporaryPath, getSettingsFilePath())
}
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