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, } }