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
This commit is contained in:
commit
cb30223fd4
47 changed files with 6599 additions and 0 deletions
126
backend/internal/calendar/caldav.go
Normal file
126
backend/internal/calendar/caldav.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue