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()) // Wie im CalDAV-Weg: Wer noch keinen Kalender hat, bekommt einen. Sonst // steht ein neues Konto vor einer leeren Ansicht und muss erst erraten, // dass es selbst einen anlegen soll – und ein CalDAV-Client, der sich // vorher verbunden hat, sähe bereits einen, den die Weboberfläche nicht // zeigt. if _, err := h.store.EnsureDefaultCalendar(r.Context(), user.ID); err != nil { response.Error(w, http.StatusInternalServerError, "list calendars failed") return } 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 }