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>
This commit is contained in:
marcandClaude Sonnet 5 committed 2026-09-03 23:03:26 +02:00
1 parent 3f1b2b8a14
commit 4d635fff20
3 files changed
+284

No files matched your search

+11
View File
@@ -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)
+248
View File
@@ -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
}
+25
View File
@@ -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 {