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>
258 lines
7.9 KiB
Go
258 lines
7.9 KiB
Go
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
|
||
}
|