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
126 lines
3.6 KiB
Go
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
|
|
}
|