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>
812 lines
26 KiB
Go
812 lines
26 KiB
Go
package calendar
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
ical "github.com/emersion/go-ical"
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/google/uuid"
|
||
"github.com/teambition/rrule-go"
|
||
|
||
"aether/internal/auth"
|
||
"aether/pkg/response"
|
||
)
|
||
|
||
// maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem
|
||
// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre
|
||
// sonst allein genug, um eine Monatsansicht lahmzulegen.
|
||
const maxOccurrences = 750
|
||
|
||
// RecurrenceDTO ist die **vereinfachte** Wiederholung, die die Oberfläche
|
||
// anbietet: alle N Tage/Wochen/Monate/Jahre, wahlweise mit Ende nach einer
|
||
// Anzahl oder an einem Datum.
|
||
//
|
||
// Eine RRULE kann weit mehr ("jeden zweiten Dienstag im Monat, außer im
|
||
// August"). Solche Regeln werden gelesen und unverändert behalten, aber
|
||
// nicht in dieses Formular gezwängt – siehe recurrenceFromRule.
|
||
type RecurrenceDTO struct {
|
||
Freq string `json:"freq"` // DAILY | WEEKLY | MONTHLY | YEARLY
|
||
Interval int `json:"interval"` // >= 1
|
||
Count int `json:"count,omitempty"`
|
||
Until *time.Time `json:"until,omitempty"`
|
||
// WEEKLY: an welchen Wochentagen (MO…SU). Leer = Wochentag des Starts.
|
||
Byday []string `json:"byday,omitempty"`
|
||
// MONTHLY/YEARLY: "DAY" wiederholt am festen Kalendertag (am 4.),
|
||
// "WEEKDAY" am n-ten Wochentag (am 1. Freitag). Leer zählt als DAY.
|
||
Mode string `json:"mode,omitempty"`
|
||
// Nur bei Mode=WEEKDAY und nur zum **Anzeigen**: der wievielte (−1 =
|
||
// letzter) und welcher Wochentag. Beim Schreiben rechnet der Server
|
||
// beides aus dem Startdatum aus – zwei Quellen für dieselbe Angabe wären
|
||
// eine zu viel, und sie liefen irgendwann auseinander.
|
||
Nth int `json:"nth,omitempty"`
|
||
Weekday string `json:"weekday,omitempty"`
|
||
}
|
||
|
||
var weekdayCodes = []string{"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
|
||
|
||
// weekdayIndex: 0 = Montag. Gos time.Weekday fängt beim Sonntag an, rrule-go
|
||
// beim Montag – diese eine Zeile ist der Übersetzer dazwischen.
|
||
func weekdayIndex(t time.Time) int { return (int(t.Weekday()) + 6) % 7 }
|
||
|
||
func weekdayCode(t time.Time) string { return weekdayCodes[weekdayIndex(t)] }
|
||
|
||
func rruleWeekday(code string) (rrule.Weekday, bool) {
|
||
switch strings.ToUpper(strings.TrimSpace(code)) {
|
||
case "MO":
|
||
return rrule.MO, true
|
||
case "TU":
|
||
return rrule.TU, true
|
||
case "WE":
|
||
return rrule.WE, true
|
||
case "TH":
|
||
return rrule.TH, true
|
||
case "FR":
|
||
return rrule.FR, true
|
||
case "SA":
|
||
return rrule.SA, true
|
||
case "SU":
|
||
return rrule.SU, true
|
||
}
|
||
return rrule.MO, false
|
||
}
|
||
|
||
// nthOfMonth: der wievielte Wochentag seiner Art der Tag im Monat ist.
|
||
//
|
||
// Der fünfte wird zu −1 ("letzter"), und das ist kein Schönheitsfehler: Einen
|
||
// fünften Freitag hat nicht jeder Monat. FREQ=MONTHLY;BYDAY=5FR ließe die
|
||
// Reihe in etwa der Hälfte aller Monate stillschweigend ausfallen – ein
|
||
// Fehler, den man erst Wochen später bemerkt.
|
||
func nthOfMonth(t time.Time) int {
|
||
if n := (t.Day()-1)/7 + 1; n < 5 {
|
||
return n
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche.
|
||
// Anders als CalDAV (byte-genaues .ics) bildet sie nur die gängigen Felder
|
||
// ab. Was hier nicht auftaucht (Teilnehmer, Anhänge, Erinnerungen), bleibt
|
||
// im gespeicherten .ics trotzdem unangetastet erhalten – geändert wird nur,
|
||
// was das Formular auch wirklich kennt.
|
||
type EventDTO struct {
|
||
UID string `json:"uid"`
|
||
Summary string `json:"summary"`
|
||
Description string `json:"description"`
|
||
Location string `json:"location"`
|
||
// Start/End bei AllDay: Mitternacht UTC des ersten bzw. des **letzten
|
||
// belegten** Tages – in beide Richtungen gleich. Die exklusive
|
||
// DTEND-Rechnung des .ics-Formats (letzter Tag + 1) bleibt vollständig
|
||
// hier im Paket; wer diese Schnittstelle nutzt, rechnet nie mit ±1 Tag.
|
||
Start time.Time `json:"start"`
|
||
End *time.Time `json:"end,omitempty"`
|
||
AllDay bool `json:"all_day"`
|
||
// Color leer = Farbe des Kalenders. Ein eigener Wert übersteuert sie nur
|
||
// für diesen einen Termin.
|
||
Color string `json:"color,omitempty"`
|
||
// Recurring: es hängt überhaupt eine Wiederholung dran.
|
||
// Recurrence: die Regel, *sofern* sie sich im Formular abbilden lässt.
|
||
// Beides zusammen ergibt den Fall „wiederkehrend, aber zu verwickelt zum
|
||
// Bearbeiten" – dann ist Recurring true und Recurrence nil.
|
||
Recurring bool `json:"recurring"`
|
||
Recurrence *RecurrenceDTO `json:"recurrence,omitempty"`
|
||
// Bei aufgeklappten Wiederholungen tragen Start/End den *einzelnen*
|
||
// Termin, SeriesStart/SeriesEnd dagegen die Reihe selbst. Das Formular
|
||
// bearbeitet immer die Reihe und braucht daher deren Anfang.
|
||
SeriesStart *time.Time `json:"series_start,omitempty"`
|
||
SeriesEnd *time.Time `json:"series_end,omitempty"`
|
||
ETag string `json:"etag"`
|
||
}
|
||
|
||
func firstEventComponent(cal *ical.Calendar) *ical.Component {
|
||
for _, child := range cal.Children {
|
||
if child.Name == "VEVENT" || child.Name == "VTODO" {
|
||
return child
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt
|
||
// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig –
|
||
// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000").
|
||
func isDateOnly(prop *ical.Prop) bool {
|
||
return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8
|
||
}
|
||
|
||
var freqToString = map[rrule.Frequency]string{
|
||
rrule.DAILY: "DAILY", rrule.WEEKLY: "WEEKLY",
|
||
rrule.MONTHLY: "MONTHLY", rrule.YEARLY: "YEARLY",
|
||
}
|
||
|
||
var stringToFreq = map[string]rrule.Frequency{
|
||
"DAILY": rrule.DAILY, "WEEKLY": rrule.WEEKLY,
|
||
"MONTHLY": rrule.MONTHLY, "YEARLY": rrule.YEARLY,
|
||
}
|
||
|
||
// recurrenceFromRule bildet eine RRULE auf das einfache Formular ab – oder
|
||
// gibt nil zurück, wenn sie mehr kann, als das Formular darstellt. Wichtig
|
||
// ist die Richtung: Im Zweifel **nil**. Eine Regel, die wir nur halb
|
||
// verstehen, dürfen wir nicht zum Bearbeiten anbieten, sonst schreibt das
|
||
// Speichern die verlorenen Teile still weg.
|
||
func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO {
|
||
if opt == nil {
|
||
return nil
|
||
}
|
||
freq, ok := freqToString[opt.Freq]
|
||
if !ok {
|
||
return nil // stündlich und feiner bietet die Oberfläche nicht an
|
||
}
|
||
// Diese Bestandteile kennt das Formular in keiner Betriebsart.
|
||
if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
|
||
len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 ||
|
||
len(opt.Byeaster) > 0 {
|
||
return nil
|
||
}
|
||
|
||
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
|
||
|
||
switch freq {
|
||
case "DAILY":
|
||
if len(opt.Byweekday) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
|
||
return nil
|
||
}
|
||
|
||
case "WEEKLY":
|
||
if len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
|
||
return nil
|
||
}
|
||
for _, wd := range opt.Byweekday {
|
||
if wd.N() != 0 {
|
||
return nil // "jeder 2. Dienstag" ist keine Wochenregel
|
||
}
|
||
dto.Byday = append(dto.Byday, weekdayCodes[wd.Day()])
|
||
}
|
||
if len(dto.Byday) == 0 {
|
||
dto.Byday = []string{weekdayCode(start)}
|
||
}
|
||
|
||
case "MONTHLY", "YEARLY":
|
||
// Bei jährlich darf der Monat genannt sein – aber nur der des Starts.
|
||
if freq == "YEARLY" {
|
||
if len(opt.Bymonth) > 1 || (len(opt.Bymonth) == 1 && opt.Bymonth[0] != int(start.Month())) {
|
||
return nil
|
||
}
|
||
} else if len(opt.Bymonth) > 0 {
|
||
return nil
|
||
}
|
||
|
||
switch {
|
||
case len(opt.Byweekday) == 1 && opt.Byweekday[0].N() != 0:
|
||
// „am n-ten Wochentag". Nur bearbeitbar, wenn die Regel auch
|
||
// wirklich auf den Starttag zeigt – sonst zeigte das Formular
|
||
// etwas anderes an, als gespeichert ist.
|
||
wd := opt.Byweekday[0]
|
||
if len(opt.Bymonthday) > 0 || wd.Day() != weekdayIndex(start) || wd.N() != nthOfMonth(start) {
|
||
return nil
|
||
}
|
||
dto.Mode, dto.Nth, dto.Weekday = "WEEKDAY", wd.N(), weekdayCode(start)
|
||
case len(opt.Byweekday) == 0:
|
||
// „am festen Kalendertag" – ausgeschrieben oder implizit.
|
||
if len(opt.Bymonthday) > 1 || (len(opt.Bymonthday) == 1 && opt.Bymonthday[0] != start.Day()) {
|
||
return nil
|
||
}
|
||
dto.Mode = "DAY"
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
if dto.Interval < 1 {
|
||
dto.Interval = 1
|
||
}
|
||
if !opt.Until.IsZero() {
|
||
u := opt.Until.UTC()
|
||
dto.Until = &u
|
||
}
|
||
return dto
|
||
}
|
||
|
||
func eventFromObject(o *Object) (*EventDTO, error) {
|
||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||
cal, err := dec.Decode()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ev := firstEventComponent(cal)
|
||
if ev == nil {
|
||
return nil, errNoEventComponent
|
||
}
|
||
|
||
summary, _ := ev.Props.Text(ical.PropSummary)
|
||
description, _ := ev.Props.Text(ical.PropDescription)
|
||
location, _ := ev.Props.Text(ical.PropLocation)
|
||
|
||
// Farbe: unser genauer Wert schlägt den benannten Ton, siehe color.go.
|
||
// Ein Termin, den ein anderer Client eingefärbt hat, bringt nur COLOR
|
||
// mit – der wird dann übersetzt.
|
||
color, _ := ev.Props.Text(propColorX)
|
||
if color == "" {
|
||
if name, _ := ev.Props.Text(propColor); name != "" {
|
||
if strings.HasPrefix(strings.TrimSpace(name), "#") {
|
||
color = normalizeHex(name)
|
||
} else {
|
||
color = css3ToHex(name)
|
||
}
|
||
}
|
||
} else {
|
||
color = normalizeHex(color)
|
||
}
|
||
|
||
dto := &EventDTO{
|
||
UID: o.UID, Summary: summary, Description: description, Location: location,
|
||
Color: color, ETag: o.ETag,
|
||
}
|
||
|
||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||
if startProp != nil {
|
||
dto.AllDay = isDateOnly(startProp)
|
||
if t, err := startProp.DateTime(time.UTC); err == nil {
|
||
dto.Start = t
|
||
}
|
||
}
|
||
if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil {
|
||
if t, err := endProp.DateTime(time.UTC); err == nil {
|
||
// Im .ics ist DTEND eines Ganztagstermins **exklusiv**: ein
|
||
// eintägiger Termin am 3. endet dort auf dem 4. Nach außen gibt
|
||
// diese Schnittstelle den letzten *belegten* Tag zurück – sonst
|
||
// müsste jede Oberfläche dieselbe Rechnung noch einmal machen
|
||
// und eine davon würde sie vergessen.
|
||
if dto.AllDay {
|
||
t = t.AddDate(0, 0, -1)
|
||
if t.Before(dto.Start) {
|
||
t = dto.Start
|
||
}
|
||
}
|
||
dto.End = &t
|
||
}
|
||
}
|
||
if dto.AllDay && dto.End == nil {
|
||
// Ohne DTEND belegt ein Ganztagstermin genau seinen Starttag.
|
||
end := dto.Start
|
||
dto.End = &end
|
||
}
|
||
|
||
if opt, err := ev.Props.RecurrenceRule(); err == nil && opt != nil {
|
||
dto.Recurring = true
|
||
dto.Recurrence = recurrenceFromRule(opt, dto.Start)
|
||
}
|
||
return dto, nil
|
||
}
|
||
|
||
// expand klappt eine Wiederholung im gefragten Zeitfenster in einzelne
|
||
// Termine auf. Ohne das zeigt eine wöchentliche Reihe im Kalender genau
|
||
// **einen** Eintrag – nämlich den ersten.
|
||
func expand(o *Object, base *EventDTO, from, to time.Time) []EventDTO {
|
||
if !base.Recurring {
|
||
// Einzeltermin: nur, wenn er das Fenster überhaupt berührt.
|
||
end := base.Start
|
||
if base.End != nil {
|
||
end = *base.End
|
||
}
|
||
if end.Before(from) || base.Start.After(to) {
|
||
return nil
|
||
}
|
||
return []EventDTO{*base}
|
||
}
|
||
|
||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||
cal, err := dec.Decode()
|
||
if err != nil {
|
||
return []EventDTO{*base}
|
||
}
|
||
ev := firstEventComponent(cal)
|
||
if ev == nil {
|
||
return []EventDTO{*base}
|
||
}
|
||
set, err := ev.RecurrenceSet(time.UTC)
|
||
if err != nil || set == nil {
|
||
return []EventDTO{*base}
|
||
}
|
||
|
||
// Die Dauer bleibt über alle Einzeltermine gleich; nur der Anfang wandert.
|
||
var dur time.Duration
|
||
if base.End != nil {
|
||
dur = base.End.Sub(base.Start)
|
||
}
|
||
seriesStart, seriesEnd := base.Start, base.End
|
||
|
||
out := make([]EventDTO, 0, 16)
|
||
for i, occ := range set.Between(from, to, true) {
|
||
if i >= maxOccurrences {
|
||
break
|
||
}
|
||
item := *base
|
||
item.Start = occ.UTC()
|
||
if seriesEnd != nil {
|
||
end := item.Start.Add(dur)
|
||
item.End = &end
|
||
}
|
||
item.SeriesStart = &seriesStart
|
||
item.SeriesEnd = seriesEnd
|
||
out = append(out, item)
|
||
}
|
||
return out
|
||
}
|
||
|
||
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
|
||
|
||
type icalError struct{ msg string }
|
||
|
||
func (e *icalError) Error() string { return e.msg }
|
||
|
||
// parseWindow liest from/to aus der Abfrage. Ohne Angabe ein großzügiges
|
||
// Fenster um heute – die Listenansicht fragt ohne Grenzen und soll trotzdem
|
||
// etwas sehen, ohne dass eine tägliche Reihe über Jahre aufgeklappt wird.
|
||
func parseWindow(r *http.Request) (from, to time.Time) {
|
||
now := time.Now().UTC()
|
||
from = now.AddDate(0, -1, 0)
|
||
to = now.AddDate(1, 0, 0)
|
||
if v := r.URL.Query().Get("from"); v != "" {
|
||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||
from = t.UTC()
|
||
}
|
||
}
|
||
if v := r.URL.Query().Get("to"); v != "" {
|
||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||
to = t.UTC()
|
||
}
|
||
}
|
||
if to.Before(from) {
|
||
to = from
|
||
}
|
||
return from, to
|
||
}
|
||
|
||
// GET /api/v1/calendars/{id}/events?from=…&to=…
|
||
func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||
user := auth.UserFromContext(r.Context())
|
||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||
if err != nil {
|
||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||
return
|
||
}
|
||
// Lesen genügt: Ein freigegebener Kalender soll sich ansehen lassen.
|
||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, false); err != nil {
|
||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||
return
|
||
}
|
||
objs, err := h.store.ListObjects(r.Context(), calID)
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "list events failed")
|
||
return
|
||
}
|
||
from, to := parseWindow(r)
|
||
|
||
out := make([]EventDTO, 0, len(objs))
|
||
for i := range objs {
|
||
base, err := eventFromObject(&objs[i])
|
||
if err != nil {
|
||
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
|
||
}
|
||
out = append(out, expand(&objs[i], base, from, to)...)
|
||
}
|
||
response.JSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
type eventBody struct {
|
||
Summary string `json:"summary"`
|
||
Description string `json:"description"`
|
||
Location string `json:"location"`
|
||
Start time.Time `json:"start"`
|
||
End *time.Time `json:"end"`
|
||
AllDay bool `json:"all_day"`
|
||
Color string `json:"color"`
|
||
Recurrence *RecurrenceDTO `json:"recurrence"`
|
||
}
|
||
|
||
func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
||
root := ical.NewCalendar()
|
||
root.Props.SetText(ical.PropVersion, "2.0")
|
||
root.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
|
||
|
||
ev := ical.NewEvent()
|
||
ev.Props.SetText(ical.PropUID, uid)
|
||
ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
|
||
if body.Summary != "" {
|
||
ev.Props.SetText(ical.PropSummary, body.Summary)
|
||
}
|
||
if body.Description != "" {
|
||
ev.Props.SetText(ical.PropDescription, body.Description)
|
||
}
|
||
if body.Location != "" {
|
||
ev.Props.SetText(ical.PropLocation, body.Location)
|
||
}
|
||
if hex := normalizeHex(body.Color); hex != "" {
|
||
// Beides schreiben: den benannten Ton für fremde Clients (DAVx5
|
||
// überträgt daraus die Terminfarbe ins Telefon), den genauen Wert
|
||
// für die eigene Palette. Siehe color.go.
|
||
if name := nearestCSS3(hex); name != "" {
|
||
ev.Props.SetText(propColor, name)
|
||
}
|
||
// Ohne SetText geschrieben: Das hängte an die unbekannte
|
||
// X-Eigenschaft ein überflüssiges VALUE=TEXT an.
|
||
p := ical.NewProp(propColorX)
|
||
p.Value = hex
|
||
ev.Props.Set(p)
|
||
}
|
||
|
||
if body.AllDay {
|
||
// Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb
|
||
// hier ausdrücklich .UTC() – der Aufrufer schickt für einen
|
||
// Ganztagstermin Mitternacht UTC, und ohne die Umrechnung würde eine
|
||
// Zeitzone östlich von UTC den Tag um eins nach hinten schieben (der
|
||
// 3. wurde zum 2.).
|
||
start := body.Start.UTC()
|
||
ev.Props.SetDate(ical.PropDateTimeStart, start)
|
||
// DTEND ist im .ics **exklusiv**: der Tag *nach* dem letzten
|
||
// belegten. Die Schnittstelle nimmt den letzten belegten Tag entgegen
|
||
// (siehe eventFromObject), also hier +1. Ohne das verletzt ein
|
||
// eintägiger Termin RFC 5545 (DTEND muss größer als DTSTART sein) und
|
||
// Clients zeigen ihn unterschiedlich an.
|
||
last := start
|
||
if body.End != nil {
|
||
last = body.End.UTC()
|
||
if last.Before(start) {
|
||
last = start
|
||
}
|
||
}
|
||
ev.Props.SetDate(ical.PropDateTimeEnd, last.AddDate(0, 0, 1))
|
||
} else {
|
||
ev.Props.SetDateTime(ical.PropDateTimeStart, body.Start.UTC())
|
||
if body.End != nil {
|
||
ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC())
|
||
}
|
||
}
|
||
|
||
if rule := ruleFromRecurrence(body); rule != nil {
|
||
ev.Props.SetRecurrenceRule(rule)
|
||
}
|
||
|
||
root.Children = append(root.Children, ev.Component)
|
||
return root
|
||
}
|
||
|
||
func ruleFromRecurrence(body eventBody) *rrule.ROption {
|
||
rec := body.Recurrence
|
||
if rec == nil {
|
||
return nil
|
||
}
|
||
freq, ok := stringToFreq[strings.ToUpper(rec.Freq)]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
interval := rec.Interval
|
||
if interval < 1 {
|
||
interval = 1
|
||
}
|
||
opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count}
|
||
|
||
// Der Start ist die Bezugsgröße für alle BY-Angaben: „am 4." und „am 1.
|
||
// Freitag" leiten sich beide aus ihm ab, statt getrennt mitgeschickt zu
|
||
// werden. So kann die Regel gar nicht erst auf einen anderen Tag zeigen
|
||
// als der Termin selbst.
|
||
start := body.Start.UTC()
|
||
switch strings.ToUpper(rec.Freq) {
|
||
case "WEEKLY":
|
||
codes := rec.Byday
|
||
if len(codes) == 0 {
|
||
codes = []string{weekdayCode(start)}
|
||
}
|
||
seen := map[string]bool{}
|
||
for _, c := range codes {
|
||
wd, ok := rruleWeekday(c)
|
||
if !ok || seen[strings.ToUpper(c)] {
|
||
continue
|
||
}
|
||
seen[strings.ToUpper(c)] = true
|
||
opt.Byweekday = append(opt.Byweekday, wd)
|
||
}
|
||
|
||
case "MONTHLY", "YEARLY":
|
||
if strings.EqualFold(rec.Mode, "WEEKDAY") {
|
||
wd, _ := rruleWeekday(weekdayCode(start))
|
||
opt.Byweekday = []rrule.Weekday{wd.Nth(nthOfMonth(start))}
|
||
} else {
|
||
opt.Bymonthday = []int{start.Day()}
|
||
}
|
||
if strings.EqualFold(rec.Freq, "YEARLY") {
|
||
opt.Bymonth = []int{int(start.Month())}
|
||
}
|
||
}
|
||
|
||
if rec.Until != nil {
|
||
// Bis zum **Ende** des genannten Tages, damit ein Termin an diesem
|
||
// Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte
|
||
// Termin je nach Uhrzeit heraus – ein Fehler, den man erst Wochen
|
||
// später bemerkt.
|
||
u := rec.Until.UTC()
|
||
opt.Until = time.Date(u.Year(), u.Month(), u.Day(), 23, 59, 59, 0, time.UTC)
|
||
}
|
||
return opt
|
||
}
|
||
|
||
// calendarOf steht vor **jedem schreibenden** Zugriff der Weboberfläche –
|
||
// Anlegen, Ändern, Umhängen, Löschen. Deshalb verlangt es das Schreibrecht:
|
||
// Ein nur lesend freigegebener Kalender darf sich ansehen lassen und trotzdem
|
||
// nichts annehmen.
|
||
func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) {
|
||
user := auth.UserFromContext(r.Context())
|
||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||
if err != nil {
|
||
return uuid.UUID{}, false
|
||
}
|
||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, calID, true); err != nil {
|
||
return uuid.UUID{}, false
|
||
}
|
||
return calID, true
|
||
}
|
||
|
||
// POST /api/v1/calendars/{id}/events
|
||
func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||
calID, ok := h.calendarOf(r)
|
||
if !ok {
|
||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||
return
|
||
}
|
||
var body eventBody
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
|
||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||
return
|
||
}
|
||
|
||
uid := uuid.New().String()
|
||
dto, err := h.writeEvent(r, calID, uid, body)
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "create event failed")
|
||
return
|
||
}
|
||
response.JSON(w, http.StatusCreated, dto)
|
||
}
|
||
|
||
// PATCH /api/v1/calendars/{id}/events/{uid}
|
||
//
|
||
// Bei einer Reihe gilt die Änderung für **alle** Termine der Reihe. Einzelne
|
||
// Ausnahmen (RECURRENCE-ID) sind bewusst nicht dabei: Sie brauchen eine
|
||
// eigene Bedienführung ("dieser Termin / alle folgenden / alle"), und ohne
|
||
// die richtet ein stilles "gilt für alles" mehr Schaden an als Nutzen.
|
||
func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||
calID, ok := h.calendarOf(r)
|
||
if !ok {
|
||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||
return
|
||
}
|
||
uid := chi.URLParam(r, "uid")
|
||
existing, err := h.store.GetObjectByUID(r.Context(), calID, uid)
|
||
if err == ErrNotFound {
|
||
response.Error(w, http.StatusNotFound, "event not found")
|
||
return
|
||
}
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "load event failed")
|
||
return
|
||
}
|
||
if dto, err := eventFromObject(existing); err == nil && dto.Recurring && dto.Recurrence == nil {
|
||
// Wiederholung, die das Formular nicht vollständig abbildet (etwa
|
||
// „jeden zweiten Dienstag"). Speichern würde die Regel auf das
|
||
// reduzieren, was das Formular kennt – also lieber ablehnen.
|
||
response.Error(w, http.StatusConflict, "this recurrence rule can only be edited in a CalDAV client")
|
||
return
|
||
}
|
||
|
||
var body eventBody
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
|
||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||
return
|
||
}
|
||
dto, err := h.writeEvent(r, calID, uid, body)
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "update event failed")
|
||
return
|
||
}
|
||
response.JSON(w, http.StatusOK, dto)
|
||
}
|
||
|
||
// POST /api/v1/calendars/{id}/events/{uid}/move {"calendar_id": "…"}
|
||
//
|
||
// Umhängen in einen anderen Kalender. Bewusst ein eigener Endpunkt und nicht
|
||
// ein weiteres Feld im PATCH: Ein Umzug lässt sich auch **ohne** jede
|
||
// Änderung am Termin auslösen (Ziehen mit der Maus, langes Drücken am Handy),
|
||
// und dafür den ganzen Termin mitschicken zu müssen wäre eine Einladung,
|
||
// nebenbei etwas zu überschreiben.
|
||
//
|
||
// Bei einer Reihe zieht die ganze Reihe um – sie ist eine einzige Datei, kein
|
||
// Bündel von Einzelterminen.
|
||
func (h *APIHandler) MoveEvent(w http.ResponseWriter, r *http.Request) {
|
||
user := auth.UserFromContext(r.Context())
|
||
fromCal, ok := h.calendarOf(r)
|
||
if !ok {
|
||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||
return
|
||
}
|
||
var body struct {
|
||
CalendarID string `json:"calendar_id"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
response.Error(w, http.StatusBadRequest, "calendar_id is required")
|
||
return
|
||
}
|
||
toCal, err := uuid.Parse(strings.TrimSpace(body.CalendarID))
|
||
if err != nil {
|
||
response.Error(w, http.StatusBadRequest, "calendar_id is required")
|
||
return
|
||
}
|
||
// In den Zielkalender muss geschrieben werden dürfen. Ohne diese Prüfung
|
||
// schöbe man Termine in fremde Kalender – und ein fremder Kalender darf
|
||
// sich nicht einmal als vorhanden verraten, deshalb 404 statt 403.
|
||
if _, err := h.store.RequireCalendar(r.Context(), user.ID, toCal, true); err != nil {
|
||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||
return
|
||
}
|
||
|
||
obj, err := h.store.MoveObject(r.Context(), fromCal, toCal, chi.URLParam(r, "uid"))
|
||
switch {
|
||
case err == ErrNotFound:
|
||
response.Error(w, http.StatusNotFound, "event not found")
|
||
return
|
||
case err == ErrConflict:
|
||
response.Error(w, http.StatusConflict, "an event with this uid already exists in the target calendar")
|
||
return
|
||
case err != nil:
|
||
response.Error(w, http.StatusInternalServerError, "move event failed")
|
||
return
|
||
}
|
||
|
||
dto, err := eventFromObject(obj)
|
||
if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "move event failed")
|
||
return
|
||
}
|
||
response.JSON(w, http.StatusOK, dto)
|
||
}
|
||
|
||
var errNotRecurring = &icalError{"calendar: event has no recurrence"}
|
||
|
||
// excludeOccurrence streicht einen einzelnen Termin aus einer Reihe, indem
|
||
// er als EXDATE vermerkt wird – die Art, wie das iCalendar-Format eine
|
||
// Ausnahme ausdrückt. Die Reihe selbst bleibt mit ihrer Regel bestehen, und
|
||
// jeder CalDAV-Client versteht das ohne Zutun.
|
||
//
|
||
// Bewusst **nicht** so gelöst, dass die Reihe in zwei Reihen zerlegt wird:
|
||
// Das änderte die UID, und alle Geräte hielten den Rest für einen neuen
|
||
// Termin.
|
||
func (h *APIHandler) excludeOccurrence(r *http.Request, calID uuid.UUID, uid string, at time.Time) error {
|
||
obj, err := h.store.GetObjectByUID(r.Context(), calID, uid)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
cal, err := ical.NewDecoder(strings.NewReader(obj.Data)).Decode()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ev := firstEventComponent(cal)
|
||
if ev == nil {
|
||
return errNoEventComponent
|
||
}
|
||
if rule, err := ev.Props.RecurrenceRule(); err != nil || rule == nil {
|
||
return errNotRecurring
|
||
}
|
||
|
||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||
allDay := startProp != nil && isDateOnly(startProp)
|
||
|
||
// Schon ausgetragen? Dann nichts tun – ein zweites EXDATE auf denselben
|
||
// Zeitpunkt wäre harmlos, aber die Datei liefe mit der Zeit voll.
|
||
for _, ex := range ev.Props[ical.PropExceptionDates] {
|
||
if t, err := ex.DateTime(time.UTC); err == nil && t.Equal(at) {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
ex := ical.NewProp(ical.PropExceptionDates)
|
||
if allDay {
|
||
ex.SetDate(at)
|
||
} else {
|
||
ex.SetDateTime(at)
|
||
}
|
||
ev.Props.Add(ex)
|
||
|
||
data, err := encodeICal(cal)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
start, end := timeRange(cal)
|
||
_, err = h.store.PutObject(r.Context(), calID, uid, obj.Component, data, etagFor(data), start, end)
|
||
return err
|
||
}
|
||
|
||
func (h *APIHandler) writeEvent(r *http.Request, calID uuid.UUID, uid string, body eventBody) (*EventDTO, error) {
|
||
cal := buildEventICal(uid, body)
|
||
data, err := encodeICal(cal)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
start, end := timeRange(cal)
|
||
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return eventFromObject(obj)
|
||
}
|
||
|
||
// DELETE /api/v1/calendars/{id}/events/{uid}[?occurrence=<RFC3339>]
|
||
//
|
||
// Ohne occurrence verschwindet der Termin ganz – bei einer Reihe also die
|
||
// **ganze** Reihe. Mit occurrence fällt nur dieser eine Termin aus; die Reihe
|
||
// bleibt bestehen und behält ihre Regel.
|
||
func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||
calID, ok := h.calendarOf(r)
|
||
if !ok {
|
||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||
return
|
||
}
|
||
uid := chi.URLParam(r, "uid")
|
||
|
||
if occ := r.URL.Query().Get("occurrence"); occ != "" {
|
||
at, err := time.Parse(time.RFC3339, occ)
|
||
if err != nil {
|
||
response.Error(w, http.StatusBadRequest, "invalid occurrence")
|
||
return
|
||
}
|
||
switch err := h.excludeOccurrence(r, calID, uid, at.UTC()); err {
|
||
case nil:
|
||
response.NoContent(w)
|
||
case ErrNotFound:
|
||
response.Error(w, http.StatusNotFound, "event not found")
|
||
case errNotRecurring:
|
||
// Kein Serientermin: Dann ist „nur dieser" dasselbe wie „ganz
|
||
// weg" – also einfach löschen, statt den Aufrufer mit einer
|
||
// Unterscheidung zu behelligen, die es hier nicht gibt.
|
||
if err := h.store.DeleteObject(r.Context(), calID, uid); err != nil && err != ErrNotFound {
|
||
response.Error(w, http.StatusInternalServerError, "delete event failed")
|
||
return
|
||
}
|
||
response.NoContent(w)
|
||
default:
|
||
response.Error(w, http.StatusInternalServerError, "delete occurrence failed")
|
||
}
|
||
return
|
||
}
|
||
|
||
if err := h.store.DeleteObject(r.Context(), calID, uid); err == ErrNotFound {
|
||
response.Error(w, http.StatusNotFound, "event not found")
|
||
return
|
||
} else if err != nil {
|
||
response.Error(w, http.StatusInternalServerError, "delete event failed")
|
||
return
|
||
}
|
||
response.NoContent(w)
|
||
}
|