Files
Aether/backend/internal/calendar/api.go
T
marcandClaude Sonnet 5 4d635fff20 feat(calendar): Phase 2 – mehrere Kalender, Export/Import als .ics
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 <noreply@anthropic.com>
2026-09-03 23:03:26 +02:00

249 lines
7.4 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())
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
}