Files
Aether/backend/internal/calendar/api.go
T
marcandClaude Opus 5 fbaf24bdbd fix(api): leere Listen als [] statt null -- Kalender blieb weiss
Ein Konto ohne Kalender bekam auf GET /api/v1/calendars die Antwort
`null`, und die Ansicht brach ab:

    for (const cal of calendars)   ->  "e is not iterable"

Nicht der Kalender ist schuld, sondern eine Eigenheit, die jeden
Listenendpunkt betrifft: In Go ist eine Liste ohne Eintraege oft nil,
und encoding/json schreibt dafuer null statt []. Jede Store-Funktion,
die mit `var out []T` anfaengt, liefert das bei leerem Ergebnis --
Favoriten, Papierkorb, Versionen, Freigaben, Etiketten. Der Kalender war
nur der, der daran zerbricht.

Deshalb die Regel dort, wo alle Antworten durchlaufen: response.JSON
macht aus einer nil-Liste eine leere. Einzelne Objekte und nil bleiben
unberuehrt. In den Stores waere es die falsche Stelle -- es gibt
Dutzende Listenendpunkte, und der naechste faengt wieder mit
`var out []T` an. Eine Regel, an die man sich erinnern muss, ist keine.

Dazu zwei Dinge, die derselbe Fall ans Licht brachte:

  - Der Web-Weg legte, anders als CalDAV, keinen Standardkalender an.
    Ein neues Konto stand vor einer leeren Ansicht und musste erraten,
    dass es selbst einen anlegen soll -- waehrend ein CalDAV-Client, der
    sich vorher verbunden hatte, bereits einen sah, den die Oberflaeche
    nicht zeigte.
  - Die Ansicht faengt eine unerwartete Antwort jetzt selbst ab (?? []).
    Der Server schickt kein null mehr; eine Seite sollte daran trotzdem
    nicht zerbrechen.

Drei Tests in pkg/response: leere Liste wird [], befuellte bleibt
unveraendert, Objekte und nil bleiben, was sie sind.

Zur Korrektur meiner frueheren Erklaerung: Die fehlenden Cache-Header
waren ein echtes Problem, aber nicht die Ursache dieses Fehlers. Der
gemeldete Bundle-Name stimmte diesmal mit dem ausgelieferten ueberein --
daran war zu sehen, dass es kein alter Stand sein konnte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:56:54 +02:00

258 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}