Add protected date management admin
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user