From 4d635fff2031a7e120d708afcb7addd6f98caa87 Mon Sep 17 00:00:00 2001 From: marc Date: Thu, 3 Sep 2026 23:03:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20Phase=202=20=E2=80=93=20mehre?= =?UTF-8?q?re=20Kalender,=20Export/Import=20als=20.ics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer APIHandler unter /api/v1/calendars (Session-Auth, wie der Rest der API) neben dem bestehenden CalDAV-Handler (App-Passwörter, /caldav/) – zwei Handler, zwei Auth-Modelle, wie schon bei /dav. - GET/POST /api/v1/calendars – Liste, Anlegen - PATCH/DELETE /api/v1/calendars/{id} – Umbenennen, Farbe, Zeitzone, Löschen (go-webdav kennt PROPPATCH und Kalender-Löschen nicht – dafür gibt's im CalDAV-Protokoll selbst keinen Weg) - GET .../export – alle Termine eines Kalenders als eine .ics-Datei, VTIMEZONE-Blöcke dedupliziert - POST .../import – .ics mit mehreren VEVENT/VTODO einlesen, jede Komponente wird ein eigenes Objekt wie bei einem einzelnen CalDAV-PUT Gegen den laufenden Container getestet (Session-Login, CSRF-Token): Anlegen, Umbenennen, zwei Termine importieren, sauberer Re-Export, Löschen, und dass ohne Login 401 statt Daten kommt. Co-Authored-By: Claude Sonnet 5 --- backend/cmd/aether/main.go | 11 ++ backend/internal/calendar/api.go | 248 +++++++++++++++++++++++++++++ backend/internal/calendar/store.go | 25 +++ 3 files changed, 284 insertions(+) create mode 100644 backend/internal/calendar/api.go diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index 86de48c..c76edc2 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -250,6 +250,7 @@ func main() { // CalDAV calStore := calendar.NewStore(pool) calHandler := calendar.NewHandler(authStore, calStore) + calAPIHandler := calendar.NewAPIHandler(calStore) // OIDC (optional – disabled if OIDC_ISSUER not set) oidcHandler, err := appoidc.New(ctx, appoidc.Config{ @@ -524,6 +525,16 @@ func main() { // es steht ohnehin an Freigaben und in Nutzerlisten. r.Get("/users/{id}/avatar", avatarHandler.Serve) + // Kalender – die Termine selbst laufen über CalDAV (/caldav/), das + // hier ist nur Verwaltung: mehrere Kalender, umbenennen, Farbe, + // löschen, sowie Export/Import als .ics. + r.Get("/calendars", calAPIHandler.List) + r.Post("/calendars", calAPIHandler.Create) + r.Patch("/calendars/{id}", calAPIHandler.Update) + r.Delete("/calendars/{id}", calAPIHandler.Delete) + r.Get("/calendars/{id}/export", calAPIHandler.Export) + r.Post("/calendars/{id}/import", calAPIHandler.Import) + r.Get("/app-passwords", authHandler.ListAppPasswords) r.Post("/app-passwords", authHandler.CreateAppPassword) r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword) diff --git a/backend/internal/calendar/api.go b/backend/internal/calendar/api.go new file mode 100644 index 0000000..8b43207 --- /dev/null +++ b/backend/internal/calendar/api.go @@ -0,0 +1,248 @@ +package calendar + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + + ical "github.com/emersion/go-ical" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "aether/internal/auth" + "aether/pkg/response" +) + +// APIHandler bedient die angemeldete Web-Oberfläche unter /api/v1/calendars +// (Session-Auth, wie jeder andere Bereich). Getrennt vom CalDAV-Handler +// (Handler in handler.go), der eigene Basic-Auth mit App-Passwörtern nutzt – +// unterschiedliche Auth-Modelle, unterschiedliche Handler, wie /dav vs. der +// Rest der API. +type APIHandler struct { + store *Store +} + +func NewAPIHandler(store *Store) *APIHandler { + return &APIHandler{store: store} +} + +// GET /api/v1/calendars +func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + cals, err := h.store.ListCalendars(r.Context(), user.ID) + if err != nil { + response.Error(w, http.StatusInternalServerError, "list calendars failed") + return + } + response.JSON(w, http.StatusOK, cals) +} + +// POST /api/v1/calendars +func (h *APIHandler) Create(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + var body struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" { + response.Error(w, http.StatusBadRequest, "name is required") + return + } + cal, err := h.store.CreateCalendar(r.Context(), user.ID, body.Name, body.Color, body.Description) + if err != nil { + response.Error(w, http.StatusInternalServerError, "create calendar failed") + return + } + response.JSON(w, http.StatusCreated, cal) +} + +// PATCH /api/v1/calendars/{id} +func (h *APIHandler) Update(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } + var body struct { + Name *string `json:"name"` + Color *string `json:"color"` + Description *string `json:"description"` + TimeZone *string `json:"timezone"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + response.Error(w, http.StatusBadRequest, "invalid body") + return + } + cal, err := h.store.UpdateCalendar(r.Context(), user.ID, id, body.Name, body.Color, body.Description, body.TimeZone) + if err == ErrNotFound { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } + if err != nil { + response.Error(w, http.StatusInternalServerError, "update calendar failed") + return + } + response.JSON(w, http.StatusOK, cal) +} + +// DELETE /api/v1/calendars/{id} +func (h *APIHandler) Delete(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } + if err := h.store.DeleteCalendar(r.Context(), user.ID, id); err == ErrNotFound { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } else if err != nil { + response.Error(w, http.StatusInternalServerError, "delete calendar failed") + return + } + response.NoContent(w) +} + +// GET /api/v1/calendars/{id}/export – ein .ics mit allen Terminen/Aufgaben +// des Kalenders zusammen, zum Sichern oder Umziehen in einen anderen Dienst. +func (h *APIHandler) Export(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } + cal, err := h.store.GetCalendar(r.Context(), user.ID, id) + if err == ErrNotFound { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } + if err != nil { + response.Error(w, http.StatusInternalServerError, "load calendar failed") + return + } + objs, err := h.store.ListObjects(r.Context(), cal.ID) + if err != nil { + response.Error(w, http.StatusInternalServerError, "list objects failed") + return + } + + out := ical.NewCalendar() + out.Props.SetText(ical.PropVersion, "2.0") + out.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE") + + // VTIMEZONE-Blöcke nur einmal je TZID mitnehmen – jedes einzeln + // gespeicherte Objekt trägt seine eigenen mit, sonst stünde dieselbe + // Definition mehrfach in der Ausgabedatei. + seenTZ := map[string]bool{} + for _, o := range objs { + dec := ical.NewDecoder(strings.NewReader(o.Data)) + c, err := dec.Decode() + if err != nil { + continue // eine kaputte Einzeldatei darf den Export nicht abbrechen + } + for _, child := range c.Children { + if child.Name == ical.CompTimezone { + tzid, _ := child.Props.Text(ical.PropTimezoneID) + if seenTZ[tzid] { + continue + } + seenTZ[tzid] = true + } + out.Children = append(out.Children, child) + } + } + + var buf bytes.Buffer + if err := ical.NewEncoder(&buf).Encode(out); err != nil { + response.Error(w, http.StatusInternalServerError, "encode calendar failed") + return + } + w.Header().Set("Content-Type", "text/calendar; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.ics"`, sanitizeFilename(cal.Name))) + w.WriteHeader(http.StatusOK) + w.Write(buf.Bytes()) +} + +// POST /api/v1/calendars/{id}/import – eine hochgeladene .ics-Datei +// einlesen. Jede darin enthaltene VEVENT/VTODO-Komponente wird ein eigenes +// Objekt, wie beim einzelnen PUT über CalDAV – nur eben viele auf einmal. +func (h *APIHandler) Import(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } + cal, err := h.store.GetCalendar(r.Context(), user.ID, id) + if err == ErrNotFound { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } + if err != nil { + response.Error(w, http.StatusInternalServerError, "load calendar failed") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 20<<20) // 20 MiB – reicht für sehr große Kalender + src, err := ical.NewDecoder(r.Body).Decode() + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid .ics file") + return + } + + var tzBlocks []*ical.Component + for _, child := range src.Children { + if child.Name == ical.CompTimezone { + tzBlocks = append(tzBlocks, child) + } + } + + imported := 0 + for _, child := range src.Children { + if child.Name != "VEVENT" && child.Name != "VTODO" { + continue + } + uid, err := child.Props.Text("UID") + if err != nil || uid == "" { + continue // ohne UID kein gültiges CalDAV-Objekt – überspringen statt den ganzen Import abzubrechen + } + + single := ical.NewCalendar() + single.Props.SetText(ical.PropVersion, "2.0") + single.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE") + single.Children = append(single.Children, tzBlocks...) + single.Children = append(single.Children, child) + + var buf bytes.Buffer + if err := ical.NewEncoder(&buf).Encode(single); err != nil { + continue + } + data := buf.String() + start, end := timeRange(single) + if _, err := h.store.PutObject(r.Context(), cal.ID, uid, child.Name, data, etagFor(data), start, end); err != nil { + continue + } + imported++ + } + + response.JSON(w, http.StatusOK, map[string]int{"imported": imported}) +} + +func sanitizeFilename(name string) string { + name = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '"': + return '_' + } + return r + }, name) + if name == "" { + return "kalender" + } + return name +} diff --git a/backend/internal/calendar/store.go b/backend/internal/calendar/store.go index ffcc0d5..b28b089 100644 --- a/backend/internal/calendar/store.go +++ b/backend/internal/calendar/store.go @@ -91,6 +91,31 @@ func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, col return &c, nil } +// UpdateCalendar ändert Name/Farbe/Beschreibung/Zeitzone. Ein nil-Feld lässt +// die bestehende Spalte unangetastet – so kann der Aufrufer nur das Feld +// schicken, das sich wirklich ändert, ohne vorher den ganzen Datensatz zu +// laden. +func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name, color, description, timezone *string) (*Calendar, error) { + var c Calendar + err := s.db.QueryRow(ctx, + `UPDATE cal_calendars SET + name = COALESCE($3, name), + color = COALESCE($4, color), + description = COALESCE($5, description), + timezone = COALESCE($6, timezone) + WHERE owner_id = $1 AND id = $2 + RETURNING id, owner_id, name, color, description, supports_todo, timezone, ctag, created_at`, + ownerID, id, name, color, description, timezone, + ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.CTag, &c.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("update calendar: %w", err) + } + return &c, nil +} + func (s *Store) DeleteCalendar(ctx context.Context, ownerID, id uuid.UUID) error { tag, err := s.db.Exec(ctx, `DELETE FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id) if err != nil {