feat: add WannPassts booking app with Go backend and Vue frontend

Initial project setup for a privacy-focused, self-hosted scheduling app:

- Go backend with JWT auth, SQLite storage, and chi router
- Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links
- Public booking pages with configurable slots and booking requests
- AES-256-GCM encryption for stored provider tokens
- Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router
- Docs, .gitignore, and .env.example for local development
This commit is contained in:
Tronax 2026-08-25 18:50:28 +02:00
commit cb30223fd4
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
47 changed files with 6599 additions and 0 deletions

View file

@ -0,0 +1,289 @@
package httpapi
import (
"crypto/rand"
"encoding/base64"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"wannpassts/internal/db"
)
const tokenTTL = 7 * 24 * time.Hour
func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Timezone string `json:"timezone"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
in.Name = strings.TrimSpace(in.Name)
if !validEmail(in.Email) {
jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
return
}
if len(in.Password) < 8 || len(in.Password) > 200 {
jsonError(w, http.StatusBadRequest, "Das Passwort muss 8200 Zeichen lang sein")
return
}
if in.Name == "" || len(in.Name) > 80 {
jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 80 Zeichen) angeben")
return
}
tz := in.Timezone
if tz == "" {
tz = "Europe/Berlin"
}
if _, err := time.LoadLocation(tz); err != nil {
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
return
}
if _, err := s.DB.UserByEmail(in.Email); err == nil {
jsonError(w, http.StatusConflict, "Diese E-Mail-Adresse ist bereits registriert")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 10)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
return
}
slug, err := s.uniqueSlug()
if err != nil {
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
return
}
u := &db.User{Email: in.Email, Name: in.Name, PasswordHash: string(hash), Slug: slug, Timezone: tz}
if err := s.DB.CreateUser(u); err != nil {
jsonError(w, http.StatusInternalServerError, "Konto konnte nicht erstellt werden")
return
}
token, err := s.signJWT(u.ID, tokenTTL)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": userToJSON(u)})
}
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
u, err := s.DB.UserByEmail(strings.ToLower(strings.TrimSpace(in.Email)))
if err != nil {
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
return
}
token, err := s.signJWT(u.ID, tokenTTL)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": userToJSON(u)})
}
func (s *Server) Me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(userFrom(r))})
}
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
u := userFrom(r)
var in struct {
Name *string `json:"name"`
Timezone *string `json:"timezone"`
SlotMinutes *int `json:"slot_minutes"`
DayStartMin *int `json:"day_start_min"`
DayEndMin *int `json:"day_end_min"`
HorizonDays *int `json:"horizon_days"`
Durations []int `json:"durations"`
Weekdays []int `json:"weekdays"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
if in.Name != nil {
name := strings.TrimSpace(*in.Name)
if name == "" || len(name) > 80 {
jsonError(w, http.StatusBadRequest, "Name muss 180 Zeichen haben")
return
}
u.Name = name
}
if in.Timezone != nil {
if _, err := time.LoadLocation(*in.Timezone); err != nil {
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
return
}
u.Timezone = *in.Timezone
}
if in.SlotMinutes != nil {
if *in.SlotMinutes < 5 || *in.SlotMinutes > 180 {
jsonError(w, http.StatusBadRequest, "Slot-Größe muss 5180 Minuten sein")
return
}
u.SlotMinutes = *in.SlotMinutes
}
if in.DayStartMin != nil {
if *in.DayStartMin < 0 || *in.DayStartMin > 1439 {
jsonError(w, http.StatusBadRequest, "Tagesbeginn ungültig")
return
}
u.DayStartMin = *in.DayStartMin
}
if in.DayEndMin != nil {
if *in.DayEndMin < 1 || *in.DayEndMin > 1440 {
jsonError(w, http.StatusBadRequest, "Tagesende ungültig")
return
}
u.DayEndMin = *in.DayEndMin
}
if u.DayStartMin >= u.DayEndMin {
jsonError(w, http.StatusBadRequest, "Tagesbeginn muss vor dem Tagesende liegen")
return
}
if in.HorizonDays != nil {
if *in.HorizonDays < 1 || *in.HorizonDays > 120 {
jsonError(w, http.StatusBadRequest, "Buchungshorizont muss 1120 Tage sein")
return
}
u.HorizonDays = *in.HorizonDays
}
if in.Durations != nil {
durations, err := cleanDurations(in.Durations)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
u.Durations = intsCSV(durations)
}
if in.Weekdays != nil {
weekdays, err := cleanWeekdays(in.Weekdays)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
u.Weekdays = intsCSV(weekdays)
}
if err := s.DB.UpdateUserSettings(u); err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
}
func (s *Server) RegenSlug(w http.ResponseWriter, r *http.Request) {
u := userFrom(r)
slug, err := s.uniqueSlug()
if err != nil {
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
return
}
if err := s.DB.UpdateUserSlug(u.ID, slug); err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
u.Slug = slug
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
}
func (s *Server) uniqueSlug() (string, error) {
for i := 0; i < 8; i++ {
b := make([]byte, 9)
if _, err := rand.Read(b); err != nil {
return "", err
}
slug := base64.RawURLEncoding.EncodeToString(b)
exists, err := s.DB.SlugExists(slug)
if err != nil {
return "", err
}
if !exists {
return slug, nil
}
}
return "", http.ErrBodyNotAllowed
}
func validEmail(s string) bool {
if len(s) < 5 || len(s) > 254 || strings.Count(s, "@") != 1 {
return false
}
at := strings.IndexByte(s, '@')
local, domain := s[:at], s[at+1:]
if local == "" || !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
return !strings.ContainsAny(s, " \t")
}
func cleanDurations(list []int) ([]int, error) {
if len(list) == 0 || len(list) > 8 {
return nil, errNew("18 Dauerangaben benötigt")
}
seen := map[int]bool{}
var out []int
for _, v := range list {
if v < 5 || v > 480 {
return nil, errNew("Dauern müssen 5480 Minuten sein")
}
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out, nil
}
func cleanWeekdays(list []int) ([]int, error) {
if len(list) == 0 || len(list) > 7 {
return nil, errNew("Mindestens ein Buchungstag nötig")
}
seen := map[int]bool{}
var out []int
for _, v := range list {
if v < 1 || v > 7 {
return nil, errNew("Wochentage müssen 1 (Mo) 7 (So) sein")
}
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out, nil
}
func errNew(msg string) error { return &simpleError{msg} }
type simpleError struct{ msg string }
func (e *simpleError) Error() string { return e.msg }

View file

@ -0,0 +1,93 @@
package httpapi
import (
"net/http"
)
func (s *Server) ListBookings(w http.ResponseWriter, r *http.Request) {
bookings, err := s.DB.BookingsForUser(userFrom(r).ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Anfragen nicht lesbar")
return
}
out := make([]BookingJSON, 0, len(bookings))
for _, b := range bookings {
out = append(out, bookingToJSON(b))
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) AcceptBooking(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := pathID(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Ungültige ID")
return
}
booking, err := s.DB.BookingByID(id, user.ID)
if err != nil {
jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden")
return
}
if booking.Status != "pending" {
jsonError(w, http.StatusConflict, "Diese Anfrage wurde bereits bearbeitet")
return
}
// Kollision mit bestehenden busy-Zeiträumen prüfen (angommene Buchungen
// und Kalendertermine liegen beide in busy_slots).
busy, err := s.DB.BusyForUser(user.ID, booking.StartTs-1, booking.EndTs+1)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar")
return
}
for _, b := range busy {
if b.BookingID.Valid && b.BookingID.Int64 == booking.ID {
continue
}
if b.StartTs < booking.EndTs && booking.StartTs < b.EndTs {
jsonError(w, http.StatusConflict, "Der Zeitraum kollidiert inzwischen mit einem anderen Termin")
return
}
}
if err := s.DB.InsertBusyForBooking(booking); err != nil {
jsonError(w, http.StatusInternalServerError, "Termin konnte nicht eingetragen werden")
return
}
if err := s.DB.SetBookingStatus(booking.ID, "accepted"); err != nil {
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
return
}
booking.Status = "accepted"
writeJSON(w, http.StatusOK, bookingToJSON(booking))
}
func (s *Server) DeclineBooking(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := pathID(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Ungültige ID")
return
}
booking, err := s.DB.BookingByID(id, user.ID)
if err != nil {
jsonError(w, http.StatusNotFound, "Anfrage nicht gefunden")
return
}
if booking.Status == "pending" {
if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil {
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
return
}
} else if booking.Status == "accepted" {
if err := s.DB.DeleteBusyByBooking(booking.ID); err != nil {
jsonError(w, http.StatusInternalServerError, "Termin konnte nicht entfernt werden")
return
}
if err := s.DB.SetBookingStatus(booking.ID, "declined"); err != nil {
jsonError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
return
}
}
booking.Status = "declined"
writeJSON(w, http.StatusOK, bookingToJSON(booking))
}

View file

@ -0,0 +1,247 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/oauth2"
"wannpassts/internal/calendar"
"wannpassts/internal/crypto"
)
func (s *Server) ListCalendars(w http.ResponseWriter, r *http.Request) {
conns, err := s.DB.ConnectionsForUser(userFrom(r).ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Verbindungen nicht lesbar")
return
}
out := make([]ConnectionJSON, 0, len(conns))
for _, c := range conns {
out = append(out, connToJSON(c))
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Ungültige ID")
return
}
if err := s.DB.DeleteConnection(id, userFrom(r).ID); err != nil {
jsonError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, nil)
}
func (s *Server) ConnectCalDAV(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
var in struct {
ServerURL string `json:"server_url"`
Username string `json:"username"`
Password string `json:"password"`
CalendarPath string `json:"calendar_path"`
DisplayName string `json:"display_name"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
in.ServerURL = strings.TrimSuffix(strings.TrimSpace(in.ServerURL), "/")
if !strings.HasPrefix(in.ServerURL, "http://") && !strings.HasPrefix(in.ServerURL, "https://") {
jsonError(w, http.StatusBadRequest, "Server-URL muss mit http:// oder https:// beginnen")
return
}
if in.Username == "" || in.Password == "" {
jsonError(w, http.StatusBadRequest, "Benutzername und Passwort werden benötigt (iCloud: App-spezifisches Passwort)")
return
}
cfg := calendar.CalDAVConnConfig{
ServerURL: in.ServerURL,
Username: in.Username,
Password: in.Password,
CalendarPath: strings.TrimSpace(in.CalendarPath),
}
if cfg.CalendarPath == "" {
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
path, name, err := calendar.DiscoverCalDAV(ctx, cfg)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
cfg.CalendarPath = path
if in.DisplayName == "" {
in.DisplayName = name
}
}
if in.DisplayName == "" {
if u, err := url.Parse(in.ServerURL); err == nil {
in.DisplayName = u.Host
} else {
in.DisplayName = "CalDAV-Kalender"
}
}
enc, err := s.encryptConfig(cfg)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
id, err := s.DB.CreateConnection(user.ID, "caldav", in.DisplayName, enc)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
return
}
s.Syncer.SyncInBackground(user)
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "caldav", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
}
func (s *Server) ConnectICS(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
var in struct {
URL string `json:"url"`
DisplayName string `json:"display_name"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
rawURL := strings.TrimSpace(in.URL)
if strings.HasPrefix(rawURL, "webcal://") {
rawURL = "https://" + strings.TrimPrefix(rawURL, "webcal://")
}
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
jsonError(w, http.StatusBadRequest, "Der Link muss mit http://, https:// oder webcal:// beginnen")
return
}
// Einmal direkt abrufen, um den Link zu validieren.
prov := calendar.NewICSProvider(calendar.ICSConnConfig{URL: rawURL}, user.Timezone)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
if _, err := prov.FetchBusy(ctx, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour)); err != nil {
jsonError(w, http.StatusBadRequest, "ICS-Link prüfen: "+err.Error())
return
}
if in.DisplayName == "" {
if u, err := url.Parse(rawURL); err == nil {
in.DisplayName = u.Host
} else {
in.DisplayName = "ICS-Kalender"
}
}
enc, err := s.encryptConfig(calendar.ICSConnConfig{URL: rawURL})
if err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
id, err := s.DB.CreateConnection(user.ID, "ics", in.DisplayName, enc)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
return
}
s.Syncer.SyncInBackground(user)
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "ics", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
}
func (s *Server) GoogleConnectStart(w http.ResponseWriter, r *http.Request) {
if s.Cfg.GoogleClientID == "" || s.Cfg.GoogleClientSecret == "" {
jsonError(w, http.StatusServiceUnavailable, "Google-Anmeldung ist nicht konfiguriert (GOOGLE_CLIENT_ID und GOOGLE_CLIENT_SECRET in .env setzen)")
return
}
state, err := s.signJWT(userFrom(r).ID, 10*time.Minute)
if err != nil {
jsonError(w, http.StatusInternalServerError, "State konnte nicht erzeugt werden")
return
}
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
s.Cfg.AppURL+"/api/calendars/google/callback")
authURL := conf.AuthCodeURL(state,
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("prompt", "consent select_account"),
)
writeJSON(w, http.StatusOK, map[string]string{"url": authURL})
}
func (s *Server) GoogleConnectCallback(w http.ResponseWriter, r *http.Request) {
fail := func(reason string) {
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=error&reason="+url.QueryEscape(reason), http.StatusFound)
}
q := r.URL.Query()
if reason := q.Get("error"); reason != "" {
fail("Google-Anmeldung abgelehnt: " + reason)
return
}
uid, err := s.verifyJWT(q.Get("state"))
if err != nil {
fail("Ungültiger or abgelaufener Verbindungsversuch bitte erneut versuchen")
return
}
user, err := s.DB.UserByID(uid)
if err != nil {
fail("Konto nicht gefunden")
return
}
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
s.Cfg.AppURL+"/api/calendars/google/callback")
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
tok, err := conf.Exchange(ctx, q.Get("code"))
if err != nil {
fail("Google-Token-Austausch fehlgeschlagen: "+cleanReason(err.Error()))
return
}
calID, summary, err := calendar.GooglePrimaryCalendar(ctx, conf.Client(ctx, tok))
if err != nil {
fail("Google-Kalender nicht lesbar: "+cleanReason(err.Error()))
return
}
name := summary
if name == "" {
name = "Google Kalender"
}
enc, err := s.encryptConfig(calendar.GoogleConnConfig{Token: tok, CalendarID: calID})
if err != nil {
fail("Speichern fehlgeschlagen")
return
}
if _, err := s.DB.CreateConnection(user.ID, "google", name, enc); err != nil {
fail("Verbindung konnte nicht gespeichert werden")
return
}
s.Syncer.SyncInBackground(user)
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=ok", http.StatusFound)
}
func (s *Server) SyncNow(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx, cancel := context.WithTimeout(context.Background(), 75*time.Second)
defer cancel()
s.Syncer.SyncUser(ctx, user)
s.ListCalendars(w, r)
}
func (s *Server) encryptConfig(cfg any) (string, error) {
data, err := json.Marshal(cfg)
if err != nil {
return "", err
}
return crypto.Encrypt(s.Cfg.EncryptionKey, data)
}
func cleanReason(msg string) string {
msg = strings.TrimSpace(msg)
if len(msg) > 200 {
msg = msg[:200] + "…"
}
return msg
}

View file

@ -0,0 +1,201 @@
package httpapi
import (
"context"
"net/http"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
"wannpassts/internal/calendar"
"wannpassts/internal/db"
)
var emailRX = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
type publicInterval struct {
Start string `json:"start"`
End string `json:"end"`
}
type publicInfo struct {
Name string `json:"name"`
Timezone string `json:"timezone"`
SlotMinutes int `json:"slot_minutes"`
DayStartMin int `json:"day_start_min"`
DayEndMin int `json:"day_end_min"`
HorizonDays int `json:"horizon_days"`
Durations []int `json:"durations"`
Weekdays []int `json:"weekdays"`
Busy []publicInterval `json:"busy"`
}
// PublicInfo liefert die Buchungsseite: Name, Buchungsregeln und ausschließlich
// busy-Zeiträume niemals Termintitel, Beschreibungen oder Teilnehmer.
func (s *Server) PublicInfo(w http.ResponseWriter, r *http.Request) {
user, err := s.DB.UserBySlug(chi.URLParam(r, "slug"))
if err != nil {
jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden")
return
}
now := time.Now()
if conns, err := s.DB.ConnectionsForUser(user.ID); err == nil && calendar.NeedsSync(conns, now) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
s.Syncer.SyncUser(ctx, user)
cancel()
}
horizonEnd := now.Add(time.Duration(user.HorizonDays+1) * 24 * time.Hour)
busy, err := s.DB.BusyForUser(user.ID, now.Add(-time.Hour).Unix(), horizonEnd.Unix())
if err != nil {
jsonError(w, http.StatusInternalServerError, "Belegung nicht lesbar")
return
}
resp := publicInfo{
Name: user.Name, Timezone: user.Timezone,
SlotMinutes: user.SlotMinutes, DayStartMin: user.DayStartMin, DayEndMin: user.DayEndMin,
HorizonDays: user.HorizonDays,
Durations: csvInts(user.Durations), Weekdays: csvInts(user.Weekdays),
Busy: make([]publicInterval, 0, len(busy)),
}
for _, iv := range mergeBusy(busy) {
resp.Busy = append(resp.Busy, publicInterval{
Start: time.Unix(iv.StartTs, 0).UTC().Format(time.RFC3339),
End: time.Unix(iv.EndTs, 0).UTC().Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, resp)
}
func mergeBusy(in []db.BusySlot) []db.BusySlot {
var out []db.BusySlot
for _, s := range in {
if n := len(out); n > 0 && s.StartTs <= out[n-1].EndTs {
if s.EndTs > out[n-1].EndTs {
out[n-1].EndTs = s.EndTs
}
continue
}
out = append(out, s)
}
return out
}
// CreateBooking nimmt eine Buchungsanfrage an: Validiert gegen Buchungsregeln
// und Belegung, legt die Anfrage mit Status "pending" an.
func (s *Server) CreateBooking(w http.ResponseWriter, r *http.Request) {
user, err := s.DB.UserBySlug(chi.URLParam(r, "slug"))
if err != nil {
jsonError(w, http.StatusNotFound, "Buchungsseite nicht gefunden")
return
}
var in struct {
Start string `json:"start"`
DurationMinutes int `json:"duration_minutes"`
Name string `json:"name"`
Email string `json:"email"`
Message string `json:"message"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
in.Name = strings.TrimSpace(in.Name)
in.Email = strings.TrimSpace(in.Email)
in.Message = strings.TrimSpace(in.Message)
if in.Name == "" || len(in.Name) > 100 {
jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 100 Zeichen) angeben")
return
}
if !emailRX.MatchString(in.Email) || len(in.Email) > 254 {
jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
return
}
if len(in.Message) > 2000 {
jsonError(w, http.StatusBadRequest, "Nachricht zu lang (max. 2000 Zeichen)")
return
}
allowed := false
for _, d := range csvInts(user.Durations) {
if d == in.DurationMinutes {
allowed = true
break
}
}
if !allowed {
jsonError(w, http.StatusBadRequest, "Ungültige Dauer")
return
}
start, err := time.Parse(time.RFC3339, strings.TrimSpace(in.Start))
if err != nil {
jsonError(w, http.StatusBadRequest, "Ungültiger Startzeitpunkt")
return
}
end := start.Add(time.Duration(in.DurationMinutes) * time.Minute)
now := time.Now()
if start.Unix() < now.Unix()-120 {
jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt in der Vergangenheit")
return
}
if start.After(now.Add(time.Duration(user.HorizonDays) * 24 * time.Hour)) {
jsonError(w, http.StatusBadRequest, "Der Zeitpunkt liegt außerhalb des Buchungszeitraums")
return
}
loc, _ := time.LoadLocation(user.Timezone)
if loc == nil {
loc = time.UTC
}
local := start.In(loc)
isoWeekday := (int(local.Weekday())+6)%7 + 1 // Mo=1 … So=7
if !containsInt(csvInts(user.Weekdays), isoWeekday) {
jsonError(w, http.StatusBadRequest, "An diesem Tag werden keine Buchungen angenommen")
return
}
minuteOfDay := local.Hour()*60 + local.Minute()
if minuteOfDay < user.DayStartMin-2 || minuteOfDay+in.DurationMinutes > user.DayEndMin+2 {
jsonError(w, http.StatusBadRequest, "Außerhalb der Buchungszeiten")
return
}
busy, err := s.DB.BusyForUser(user.ID, start.Unix()-1, end.Unix()+1)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Belegung nicht prüfbar")
return
}
for _, b := range busy {
if b.StartTs < end.Unix() && start.Unix() < b.EndTs {
jsonError(w, http.StatusConflict, "Dieser Zeitraum ist leider bereits belegt")
return
}
}
booking := &db.Booking{
UserID: user.ID, StartTs: start.Unix(), EndTs: end.Unix(),
RequesterName: in.Name, RequesterEmail: in.Email, Message: in.Message,
}
if err := s.DB.CreateBooking(booking); err != nil {
jsonError(w, http.StatusInternalServerError, "Anfrage konnte nicht gespeichert werden")
return
}
booking.Status = "pending"
writeJSON(w, http.StatusCreated, map[string]any{
"id": booking.ID, "status": booking.Status,
})
}
func containsInt(list []int, v int) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}

View file

@ -0,0 +1,280 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/golang-jwt/jwt/v5"
"wannpassts/internal/calendar"
"wannpassts/internal/config"
"wannpassts/internal/db"
)
type Server struct {
DB *db.DB
Cfg *config.Config
Syncer *calendar.Syncer
}
func New(database *db.DB, cfg *config.Config, syncer *calendar.Syncer) *Server {
return &Server{DB: database, Cfg: cfg, Syncer: syncer}
}
type ctxKey int
const ctxUser ctxKey = 1
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins(s.Cfg.FrontendURL),
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
MaxAge: 300,
}))
r.Get("/api/health", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Route("/api", func(api chi.Router) {
api.Post("/auth/register", s.Register)
api.Post("/auth/login", s.Login)
api.Get("/calendars/google/callback", s.GoogleConnectCallback)
api.Get("/public/{slug}", s.PublicInfo)
api.Post("/public/{slug}/bookings", s.CreateBooking)
api.Group(func(prt chi.Router) {
prt.Use(s.AuthMiddleware)
prt.Get("/me", s.Me)
prt.Patch("/me", s.UpdateMe)
prt.Post("/me/slug", s.RegenSlug)
prt.Get("/calendars", s.ListCalendars)
prt.Delete("/calendars/{id}", s.DeleteCalendar)
prt.Post("/calendars/caldav", s.ConnectCalDAV)
prt.Post("/calendars/ics", s.ConnectICS)
prt.Get("/calendars/google/start", s.GoogleConnectStart)
prt.Post("/calendars/sync", s.SyncNow)
prt.Get("/bookings", s.ListBookings)
prt.Post("/bookings/{id}/accept", s.AcceptBooking)
prt.Post("/bookings/{id}/decline", s.DeclineBooking)
})
})
if s.Cfg.StaticDir != "" {
if st, err := os.Stat(s.Cfg.StaticDir); err == nil && st.IsDir() {
static := s.Cfg.StaticDir
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
p := filepath.Join(static, filepath.Clean(req.URL.Path))
if st, err := os.Stat(p); err == nil && !st.IsDir() {
http.ServeFile(w, req, p)
return
}
http.ServeFile(w, req, filepath.Join(static, "index.html"))
})
}
}
return r
}
func allowedOrigins(frontendURL string) []string {
origins := []string{"http://localhost:5173", "http://127.0.0.1:5173"}
for _, o := range strings.Split(frontendURL, ",") {
o = strings.TrimSpace(o)
if o != "" && !contains(origins, o) {
origins = append(origins, o)
}
}
return origins
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// --- Auth Middleware & JWT ---
func (s *Server) signJWT(userID int64, ttl time.Duration) (string, error) {
claims := jwt.RegisteredClaims{
Subject: strconv.FormatInt(userID, 10),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return tok.SignedString([]byte(s.Cfg.JWTSecret))
}
func (s *Server) verifyJWT(tokenStr string) (int64, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unerwartete Signaturmethode")
}
return []byte(s.Cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
return 0, errors.New("Token ungültig oder abgelaufen")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return 0, errors.New("Claims unlesbar")
}
sub, _ := claims["sub"].(string)
id, err := strconv.ParseInt(sub, 10, 64)
if err != nil {
return 0, errors.New("Subject unlesbar")
}
return id, nil
}
func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
jsonError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
uid, err := s.verifyJWT(strings.TrimSpace(h[len("Bearer "):]))
if err != nil {
jsonError(w, http.StatusUnauthorized, "Sitzung ungültig oder abgelaufen")
return
}
user, err := s.DB.UserByID(uid)
if err != nil {
jsonError(w, http.StatusUnauthorized, "Konto nicht gefunden")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, user)))
})
}
func userFrom(r *http.Request) *db.User {
return r.Context().Value(ctxUser).(*db.User)
}
// --- JSON helpers ---
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}
func jsonError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func decodeBody(r *http.Request, v any) error {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
return dec.Decode(v)
}
func pathID(r *http.Request) (int64, error) {
return strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
}
// --- JSON Darstellungen ---
type UserJSON struct {
ID int64 `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Slug string `json:"slug"`
Timezone string `json:"timezone"`
SlotMinutes int `json:"slot_minutes"`
DayStartMin int `json:"day_start_min"`
DayEndMin int `json:"day_end_min"`
HorizonDays int `json:"horizon_days"`
Durations []int `json:"durations"`
Weekdays []int `json:"weekdays"`
CreatedTs int64 `json:"created_ts"`
}
func userToJSON(u *db.User) UserJSON {
return UserJSON{
ID: u.ID, Email: u.Email, Name: u.Name, Slug: u.Slug, Timezone: u.Timezone,
SlotMinutes: u.SlotMinutes, DayStartMin: u.DayStartMin, DayEndMin: u.DayEndMin,
HorizonDays: u.HorizonDays, Durations: csvInts(u.Durations), Weekdays: csvInts(u.Weekdays),
CreatedTs: u.CreatedTs,
}
}
func csvInts(csv string) []int {
var out []int
for _, p := range strings.Split(csv, ",") {
if v, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
out = append(out, v)
}
}
return out
}
func intsCSV(list []int) string {
parts := make([]string, len(list))
for i, v := range list {
parts[i] = strconv.Itoa(v)
}
return strings.Join(parts, ",")
}
type ConnectionJSON struct {
ID int64 `json:"id"`
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
LastSyncedTs *int64 `json:"last_synced_ts"`
LastError *string `json:"last_error"`
CreatedTs int64 `json:"created_ts"`
}
func connToJSON(c *db.Connection) ConnectionJSON {
out := ConnectionJSON{ID: c.ID, Provider: c.Provider, DisplayName: c.DisplayName, CreatedTs: c.CreatedTs}
if c.LastSyncedTs.Valid {
v := c.LastSyncedTs.Int64
out.LastSyncedTs = &v
}
if c.LastError.Valid && c.LastError.String != "" {
v := c.LastError.String
out.LastError = &v
}
return out
}
type BookingJSON struct {
ID int64 `json:"id"`
Start string `json:"start"`
End string `json:"end"`
RequesterName string `json:"requester_name"`
RequesterEmail string `json:"requester_email"`
Message string `json:"message"`
Status string `json:"status"`
CreatedTs int64 `json:"created_ts"`
}
func bookingToJSON(b *db.Booking) BookingJSON {
return BookingJSON{
ID: b.ID,
Start: time.Unix(b.StartTs, 0).UTC().Format(time.RFC3339),
End: time.Unix(b.EndTs, 0).UTC().Format(time.RFC3339),
RequesterName: b.RequesterName, RequesterEmail: b.RequesterEmail,
Message: b.Message, Status: b.Status, CreatedTs: b.CreatedTs,
}
}