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 }