Date-Planer
+Terminverwaltung
+Melde dich an, um verfügbare Tage zu verwalten.
+ + +diff --git a/Dockerfile b/Dockerfile index 5d092e0..70291ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,11 @@ WORKDIR /app # Copy binary and static front-end assets COPY --from=builder /app/server /app/server COPY --from=builder /app/public /app/public +COPY --from=builder /app/appsettings.json /app/appsettings.default.json +COPY start.sh /app/start.sh + +RUN chmod +x /app/start.sh EXPOSE 8080 -ENTRYPOINT ["/app/server"] +ENTRYPOINT ["/app/start.sh"] diff --git a/README.md b/README.md index 9dcea11..b6e5c84 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,19 @@ Trage in der `.env` deine Server- und SMTP-Zugangsdaten ein: *(Hinweis: Wenn `SMTP_HOST` oder `TO_EMAIL` leer bleiben, läuft der Server im Testmodus ohne E-Mail-Versand.)* -### 2. Aktivitäten & Termine anpassen (`public/appsettings.json`) +### 2. Aktivitäten & Termine anpassen (`appsettings.json`) -Passe in `public/appsettings.json` die zur Auswahl stehenden Aktivitäten sowie die verfügbaren Tage an: +Passe in `appsettings.json` die zur Auswahl stehenden Aktivitäten, verfügbaren Tage und das Admin-Passwort an: +- **`adminPassword`**: Passwort für die Terminverwaltung unter `/admin`. Ändere den Standardwert `change-me`, bevor du die Anwendung veröffentlichst. - **`activities`**: Liste von Objekten mit `name`, `icon` (Unicode Emoji) und `type` (`short` oder `long`). - **`availableDates`**: Liste von verfügbaren Tagen mit `date` (`YYYY-MM-DD`) und `type` (`short` oder `long`). - **`names`**: Optional vordefinierte Namen zur Auswahl (ist die Liste leer, wird ein Standard-Fallback angeboten). +Die Terminliste lässt sich auch über die passwortgeschützte Verwaltung unter `http://localhost:8080/admin` bearbeiten. Das Passwort bleibt dabei auf dem Server und wird nicht an Besucher ausgeliefert. + +Bei Docker Compose werden `appsettings.json` und `booked_dates.json` dauerhaft unter `/docker/data/date` gespeichert. Beim ersten Start legt der Container die Einstellungen dort aus der im Image enthaltenen Vorlage an. Ändere danach den Standardwert `change-me` in `/docker/data/date/appsettings.json` oder übernimm vor dem ersten Start eine vorbereitete Konfigurationsdatei. + --- ## 🚀 Inbetriebnahme diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..ff86481 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,190 @@ +{ + "activities": [ + { + "name": "Picknick auf der Wiese", + "icon": "🧺", + "type": "short" + }, + { + "name": "Cocktailabend zu zweit", + "icon": "🍸", + "type": "long" + }, + { + "name": "Massage zu Hause", + "icon": "💆", + "type": "short" + }, + { + "name": "Ausgedehnte Wanderung", + "icon": "🥾", + "type": "long" + }, + { + "name": "Kinofilm", + "icon": "🎬", + "type": "short" + }, + { + "name": "Sushi selber machen", + "icon": "🍣", + "type": "short" + }, + { + "name": "Untersetzer aus Ton basteln", + "icon": "🪨", + "type": "long" + }, + { + "name": "Freizeitpark", + "icon": "🎢", + "type": "long" + }, + { + "name": "Spaziergang im Wald", + "icon": "🌲", + "type": "short" + }, + { + "name": "Kirschbier Tasting", + "icon": "🍒", + "type": "short" + }, + { + "name": "Nudeln selber machen", + "icon": "🍜", + "type": "long" + }, + { + "name": "Chillen im Freibad", + "icon": "🏊", + "type": "short" + }, + { + "name": "Film zu Hause + Essen bestellen", + "icon": "🛋️", + "type": "long" + }, + { + "name": "Squash spielen", + "icon": "🏸", + "type": "short" + }, + { + "name": "Essen im Namaste", + "icon": "🍛", + "type": "short" + }, + { + "name": "Museum für Gegenwartskunst", + "icon": "🖼️", + "type": "short" + }, + { + "name": "Playstation Co-Op", + "icon": "🎮", + "type": "short" + }, + { + "name": "Etwas eigenes", + "icon": "✏️", + "type": "custom" + } + ], + "availableDates": [ + { + "date": "2026-08-18", + "type": "short" + }, + { + "date": "2026-08-20", + "type": "short" + }, + { + "date": "2026-08-22", + "type": "long" + }, + { + "date": "2026-08-23", + "type": "long" + }, + { + "date": "2026-08-24", + "type": "short" + }, + { + "date": "2026-08-26", + "type": "short" + }, + { + "date": "2026-08-27", + "type": "short" + }, + { + "date": "2026-08-28", + "type": "short" + }, + { + "date": "2026-08-29", + "type": "long" + }, + { + "date": "2026-08-31", + "type": "short" + }, + { + "date": "2026-09-01", + "type": "short" + }, + { + "date": "2026-09-07", + "type": "short" + }, + { + "date": "2026-09-08", + "type": "short" + }, + { + "date": "2026-09-14", + "type": "short" + }, + { + "date": "2026-09-15", + "type": "short" + }, + { + "date": "2026-09-17", + "type": "short" + }, + { + "date": "2026-09-18", + "type": "short" + }, + { + "date": "2026-09-25", + "type": "short" + }, + { + "date": "2026-09-26", + "type": "long" + }, + { + "date": "2026-09-27", + "type": "short" + }, + { + "date": "2026-09-28", + "type": "short" + }, + { + "date": "2026-09-29", + "type": "short" + }, + { + "date": "2026-09-30", + "type": "short" + } + ], + "names": [], + "adminPassword": "change-me" +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c91a55c..a50aef1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: date-wizard: build: @@ -7,14 +5,23 @@ services: dockerfile: Dockerfile container_name: date-wizard restart: unless-stopped - ports: - - "8080:8080" + networks: + - proxy env_file: - .env + environment: + SETTINGS_FILE: /data/appsettings.json + BOOKED_DATES_FILE: /data/booked_dates.json + volumes: + - /docker/data/date:/data labels: - "traefik.enable=true" - - "traefik.http.routers.date-wizard.rule=Host(`date.example.com`)" + - "traefik.http.routers.date-wizard.rule=Host(`date.allen.beging.de`) || Host(`date.beging.de`)" - "traefik.http.routers.date-wizard.entrypoints=websecure" - "traefik.http.routers.date-wizard.tls=true" - - "traefik.http.routers.date-wizard.tls.certresolver=letsencrypt" + - "traefik.http.routers.date-wizard.tls.certresolver=leresolver" - "traefik.http.services.date-wizard.loadbalancer.server.port=8080" + +networks: + proxy: + external: true diff --git a/main.go b/main.go index 858f1c0..7f9ba9b 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/public/activity.js b/public/activity.js index 9320895..aaaf9cf 100644 --- a/public/activity.js +++ b/public/activity.js @@ -32,7 +32,7 @@ return act && (act.name === "Etwas eigenes" || act.name === "Sonstiges" || act.type === "custom"); } - fetch("appsettings.json") + fetch("/api/settings", { cache: "no-store" }) .then((response) => response.json()) .then((settings) => { const allActivities = settings.activities || []; diff --git a/public/admin.css b/public/admin.css new file mode 100644 index 0000000..f2b2904 --- /dev/null +++ b/public/admin.css @@ -0,0 +1,260 @@ +.page-admin { + background: #f8f4ec; + font-size: 1rem; +} + +.admin-shell { + width: min(100% - 32px, 720px); + margin: 0 auto; + padding: 40px 0 56px; +} + +.login-view { + width: min(100%, 400px); + margin: 12vh auto 0; + padding: 32px; + background: var(--color-white); + border: 1px solid #eaded1; + border-radius: 8px; + box-shadow: 0 12px 30px rgba(74, 44, 61, 0.12); +} + +.eyebrow { + margin-bottom: 6px; + color: #8c6d52; + font-family: var(--font-heading); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.login-copy, +.section-heading p { + color: var(--color-text-light); +} + +.login-form, +.date-form { + display: grid; + gap: 12px; + margin-top: 24px; +} + +.login-form label, +.date-form > label, +.type-selector legend { + color: var(--color-text); + font-family: var(--font-heading); + font-size: 0.92rem; + font-weight: 700; +} + +.login-form input, +.date-form > input { + width: 100%; + min-height: 46px; + padding: 10px 12px; + border: 1px solid #cfbba9; + border-radius: 6px; + background: #fffefa; + color: var(--color-text); + font: inherit; +} + +.login-form input:focus, +.date-form > input:focus, +.date-type-select:focus { + outline: 3px solid rgba(224, 87, 126, 0.28); + outline-offset: 1px; + border-color: var(--color-rose); +} + +.login-form .btn, +.date-form .btn { + min-height: 48px; + border-radius: 6px; + font-size: 1rem; +} + +.form-message { + min-height: 1.5em; + margin: 12px 0 0; + color: var(--color-rose-dark); + font-weight: 700; +} + +.form-message.is-success { + color: #287540; +} + +.admin-view { + display: grid; + gap: 32px; +} + +.admin-header, +.dates-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.admin-header h1 { + margin-bottom: 0; +} + +.text-action { + padding: 8px 0; + border: 0; + background: transparent; + color: var(--color-rose-dark); + font: inherit; + font-weight: 700; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; +} + +.admin-section { + padding-top: 24px; + border-top: 1px solid #dfcdbd; +} + +.section-heading h2 { + margin-bottom: 4px; + font-size: 1.25rem; +} + +.section-heading p { + margin-bottom: 0; +} + +.type-selector { + display: flex; + gap: 8px; + margin: 4px 0 2px; + padding: 0; + border: 0; +} + +.type-selector legend { + margin-bottom: 8px; +} + +.type-selector label { + flex: 1; +} + +.type-selector input { + position: absolute; + opacity: 0; +} + +.type-selector span { + display: block; + padding: 10px 12px; + border: 1px solid #cfbba9; + border-radius: 6px; + background: #fffefa; + color: var(--color-text-light); + font-family: var(--font-heading); + font-weight: 700; + text-align: center; + cursor: pointer; +} + +.type-selector input:checked + span { + border-color: var(--color-rose-dark); + background: var(--color-rose-dark); + color: var(--color-white); +} + +.type-selector input:focus-visible + span { + outline: 3px solid rgba(224, 87, 126, 0.28); + outline-offset: 1px; +} + +.dates-list { + display: grid; + gap: 10px; + margin: 18px 0 0; + padding: 0; + list-style: none; +} + +.date-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 118px auto; + align-items: center; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid #e5d8ce; +} + +.date-label { + font-family: var(--font-heading); + font-weight: 700; +} + +.date-type-select { + min-height: 38px; + padding: 6px 28px 6px 8px; + border: 1px solid #cfbba9; + border-radius: 5px; + background: #fffefa; + color: var(--color-text); + font: inherit; +} + +.delete-date-btn { + min-height: 38px; + padding: 6px 10px; + border: 1px solid #c84a62; + border-radius: 5px; + background: transparent; + color: #a83049; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.delete-date-btn:hover, +.delete-date-btn:focus-visible { + background: #fff0f2; +} + +.empty-dates { + margin: 18px 0 0; + color: var(--color-text-light); +} + +.hidden { + display: none !important; +} + +@media (max-width: 480px) { + .admin-shell { + width: min(100% - 24px, 720px); + padding-top: 24px; + } + + .login-view { + margin-top: 8vh; + padding: 24px; + } + + .date-row { + grid-template-columns: minmax(0, 1fr) auto; + } + + .date-type-select { + grid-column: 1; + } + + .delete-date-btn { + grid-column: 2; + grid-row: 1 / span 2; + } +} \ No newline at end of file diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..ead6827 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,74 @@ + + +
+ + +Date-Planer
+Melde dich an, um verfügbare Tage zu verwalten.
+ + +