wannpassts/backend/internal/calendar/caldav.go
Tronax cb30223fd4
feat: add WannPassts booking app with Go backend and Vue frontend
Initial project setup for a privacy-focused, self-hosted scheduling app:

- Go backend with JWT auth, SQLite storage, and chi router
- Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links
- Public booking pages with configurable slots and booking requests
- AES-256-GCM encryption for stored provider tokens
- Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router
- Docs, .gitignore, and .env.example for local development
2026-08-25 18:50:28 +02:00

126 lines
3.6 KiB
Go

package calendar
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav/caldav"
)
// CalDAVConnConfig deckt iCloud (https://caldav.icloud.com mit App-spezifischem
// Passwort) sowie Fastmail, Nextcloud und andere CalDAV-Server ab.
type CalDAVConnConfig struct {
ServerURL string `json:"server_url"`
Username string `json:"username"`
Password string `json:"password"`
CalendarPath string `json:"calendar_path"`
}
type CalDAVProvider struct {
cfg CalDAVConnConfig
}
func NewCalDAVProvider(cfg CalDAVConnConfig) *CalDAVProvider {
return &CalDAVProvider{cfg: cfg}
}
type basicAuthTransport struct {
username, password string
}
func (t *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
clone := req.Clone(req.Context())
clone.SetBasicAuth(t.username, t.password)
return http.DefaultTransport.RoundTrip(clone)
}
func newCalDAVClient(cfg CalDAVConnConfig) (*caldav.Client, error) {
hc := &http.Client{Transport: &basicAuthTransport{cfg.Username, cfg.Password}}
return caldav.NewClient(hc, cfg.ServerURL)
}
// DiscoverCalDAV findet automatisch den ersten Kalender mit Terminen (VEVENT)
// auf dem Server. Für iCloud: Apple-ID als Benutzername und ein im Apple-ID-
// Konto erzeugtes App-spezifisches Passwort.
func DiscoverCalDAV(ctx context.Context, cfg CalDAVConnConfig) (path, name string, err error) {
c, err := newCalDAVClient(cfg)
if err != nil {
return "", "", fmt.Errorf("CalDAV-Server-URL ungültig: %w", err)
}
principal, err := c.FindCurrentUserPrincipal(ctx)
if err != nil {
return "", "", fmt.Errorf("CalDAV: Anmeldung/Principal fehlgeschlagen (Zugangsdaten prüfen; iCloud braucht ein App-spezifisches Passwort): %w", err)
}
homeSet, err := c.FindCalendarHomeSet(ctx, principal)
if err != nil {
return "", "", fmt.Errorf("CalDAV: Kalender-Verzeichnis nicht gefunden: %w", err)
}
cals, err := c.FindCalendars(ctx, homeSet)
if err != nil {
return "", "", fmt.Errorf("CalDAV: Kalender konnten nicht geladen werden: %w", err)
}
for _, cal := range cals {
for _, comp := range cal.SupportedComponentSet {
if strings.EqualFold(comp, "VEVENT") {
return cal.Path, cal.Name, nil
}
}
}
return "", "", fmt.Errorf("CalDAV: kein Kalender mit Terminen gefunden")
}
func (p *CalDAVProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) {
c, err := newCalDAVClient(p.cfg)
if err != nil {
return nil, err
}
// Der Zeitfilter auf dem Server expandiert auch Serientermine (RFC 4791).
query := &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
Comps: []caldav.CalendarCompRequest{{Name: "VEVENT"}},
},
CompFilter: caldav.CompFilter{
Name: "VCALENDAR",
Comps: []caldav.CompFilter{{
Name: "VEVENT",
Start: from,
End: to,
}},
},
}
objects, err := c.QueryCalendar(ctx, p.cfg.CalendarPath, query)
if err != nil {
return nil, fmt.Errorf("CalDAV-Abfrage fehlgeschlagen: %w", err)
}
var out []Interval
for _, obj := range objects {
if obj.Data == nil {
continue
}
for _, ev := range obj.Data.Events() {
if prop := ev.Props.Get(ical.PropStatus); prop != nil && strings.EqualFold(prop.Value, "CANCELLED") {
continue
}
// Transparente Termine ("Verfügbar") blockieren keine Zeit.
if prop := ev.Props.Get("TRANSP"); prop != nil && strings.EqualFold(prop.Value, "TRANSPARENT") {
continue
}
start, err := ev.DateTimeStart(time.UTC)
if err != nil {
continue
}
end, err := ev.DateTimeEnd(time.UTC)
if err != nil || !end.After(start) {
continue
}
out = append(out, Interval{Start: start, End: end})
}
}
return out, nil
}