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