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>
269 lines
8.4 KiB
Go
269 lines
8.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())
|
||
// 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
|
||
}
|