Files
Aether/backend/internal/calendar/api.go
T
marcandClaude Opus 5 5d90c0a2d2 feat(calendar): Kalender an Nutzer und Gruppen freigeben
Nicht als eigene Tabelle, wie im Plan vorgesehen: acl_entries beantwortet
fuer Ordner und Dateien laengst dieselbe Frage -- wer darf was, direkt oder
ueber eine Gruppe. Ein zweites Rechtesystem daneben waere eine zweite
Antwort auf dieselbe Frage, mit eigener Gruppenaufloesung, eigenem
Widerruf, eigenem Pruefpfad; die Luecke dazwischen faellt erst auf, wenn
jemand sieht, was er nicht sehen soll. Migration 25 haengt deshalb nur eine
dritte Ressourcenspalte an.

Der Gewinn: Freigeben an eine **Gruppe** kostete keine Zeile extra.

Drei Entscheidungen, die nicht offensichtlich waren:

* Ein fremder Kalender steht immer unter seiner UUID, nie unter dem
  Pfadsegment des Eigentuemers -- das hat der sich ausgesucht, und zwei
  Leute duerfen denselben Namen waehlen. Sonst laegen im Kalenderbestand
  des Empfaengers zwei Kalender auf derselben Adresse.
* Alle Wege laufen durch RequireCalendar. CalDAV und Weboberflaeche sind
  zwei getrennte Zugaenge zu denselben Daten; ein Recht, das nur einer von
  beiden prueft, ist kein Recht. Im CalDAV-Teil genuegte, resolveCalendar
  umzustellen -- dort kommt jede Operation vorbei.
* Wer ein Recht geliehen hat, darf es nicht weiterverleihen: Umbenennen,
  Loeschen und Freigeben bleiben auch mit Schreibrecht beim Eigentuemer.

Oberflaeche: fremde Kalender tragen Eigentuemer und "(nur lesen)", das
Menue zeigt nur, was auch geht, und ein Termin laesst sich gar nicht erst
in einen Kalender ziehen, den der Server danach ablehnen wuerde.

scripts/kalender-teilen-selbsttest.py, 41 Pruefungen, jede ueber beide
Zugaenge. Er beginnt damit, dass der Empfaenger vorher nichts sieht --
ohne diesen Anfang beweist der Rest nichts.

Dabei hatte der Test selbst eine Falle: Mit festem Testdatum lief die
Terminliste aus ihrem Standardzeitraum, und die Pruefung "beim Eigentuemer
ist nichts angekommen" bestand **leer**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:18 +02:00

269 lines
8.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())
// 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
}
// Eigene **und** freigegebene. Jeder Eintrag trägt sein Recht mit, damit
// die Oberfläche einen nur lesbaren Kalender gar nicht erst zum
// Bearbeiten anbietet.
cals, err := h.store.ListAccessible(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
}
// Ausgeben darf, wer lesen darf – auch bei einem freigegebenen Kalender.
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, false)
if err == ErrNotFound {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "load calendar failed")
return
}
cal := &zugriff.Calendar
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
}
// Hineinschreiben darf nur, wer schreiben darf.
zugriff, err := h.store.RequireCalendar(r.Context(), user.ID, id, true)
if err == ErrNotFound {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
if err == ErrReadOnly {
response.Error(w, http.StatusForbidden, "calendar is shared read-only")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "load calendar failed")
return
}
cal := &zugriff.Calendar
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
}