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:
Tronax 2026-08-25 18:50:28 +02:00
commit cb30223fd4
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
47 changed files with 6599 additions and 0 deletions

View 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
}

View file

@ -0,0 +1,151 @@
package calendar
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// GoogleConnConfig ist der verschlüsselt gespeicherte Zustand einer
// Google-Kalenderverbindung.
type GoogleConnConfig struct {
Token *oauth2.Token `json:"token"`
CalendarID string `json:"calendar_id"`
}
// GoogleOAuthConfig baut die OAuth2-Konfiguration. Die FreeBusy-API von Google
// liefert ausschließlich busy-Zeiträume gar keine Termindetails.
func GoogleOAuthConfig(clientID, clientSecret, redirectURL string) *oauth2.Config {
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{"https://www.googleapis.com/auth/calendar.readonly"},
Endpoint: google.Endpoint,
}
}
type GoogleProvider struct {
conf *oauth2.Config
cfg GoogleConnConfig
onTokenSaved func(*oauth2.Token) // wird aufgerufen, wenn das Token erneuert wurde
}
func NewGoogleProvider(conf *oauth2.Config, cfg GoogleConnConfig, onTokenSaved func(*oauth2.Token)) *GoogleProvider {
return &GoogleProvider{conf: conf, cfg: cfg, onTokenSaved: onTokenSaved}
}
type googleFreeBusyRequest struct {
TimeMin string `json:"timeMin"`
TimeMax string `json:"timeMax"`
Items []googleFreeBusyRequestItem `json:"items"`
}
type googleFreeBusyRequestItem struct {
ID string `json:"id"`
}
type googleFreeBusyResponse struct {
Calendars map[string]struct {
Busy []struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
} `json:"busy"`
Errors []struct {
Reason string `json:"reason"`
} `json:"errors"`
} `json:"calendars"`
}
func (p *GoogleProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) {
ts := p.conf.TokenSource(ctx, p.cfg.Token)
tok, err := ts.Token()
if err != nil {
return nil, fmt.Errorf("Google-Zugriff ungültig (neu verbinden): %w", err)
}
if tok.AccessToken != p.cfg.Token.AccessToken {
saved := *tok
p.cfg.Token = &saved
if p.onTokenSaved != nil {
p.onTokenSaved(&saved)
}
}
body, err := json.Marshal(googleFreeBusyRequest{
TimeMin: from.UTC().Format(time.RFC3339),
TimeMax: to.UTC().Format(time.RFC3339),
Items: []googleFreeBusyRequestItem{{ID: p.cfg.CalendarID}},
})
if err != nil {
return nil, err
}
client := oauth2.NewClient(ctx, ts)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://www.googleapis.com/calendar/v3/freeBusy", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("Google FreeBusy-Anfrage fehlgeschlagen: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Google FreeBusy-Fehler (HTTP %d): %.200s", resp.StatusCode, string(raw))
}
var out googleFreeBusyResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("Google FreeBusy-Antwort unlesbar: %w", err)
}
cal, ok := out.Calendars[p.cfg.CalendarID]
if !ok {
return nil, fmt.Errorf("Google: Kalender %s nicht in Antwort", p.cfg.CalendarID)
}
if len(cal.Errors) > 0 {
return nil, fmt.Errorf("Google: %s", cal.Errors[0].Reason)
}
intervals := make([]Interval, 0, len(cal.Busy))
for _, b := range cal.Busy {
intervals = append(intervals, Interval{Start: b.Start, End: b.End})
}
return intervals, nil
}
// GooglePrimaryCalendar liefert ID und Namen des Primärkalenders genutzt
// direkt nach dem OAuth-Flow.
func GooglePrimaryCalendar(ctx context.Context, client *http.Client) (id, summary string, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://www.googleapis.com/calendar/v3/calendars/primary", nil)
if err != nil {
return "", "", err
}
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("Google: Primärkalender nicht lesbar (HTTP %d): %.200s", resp.StatusCode, string(raw))
}
var cal struct {
ID string `json:"id"`
Summary string `json:"summary"`
}
if err := json.Unmarshal(raw, &cal); err != nil {
return "", "", err
}
return cal.ID, cal.Summary, nil
}

View file

@ -0,0 +1,270 @@
package calendar
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// ICSConnConfig: Abo eines beliebigen Kalenders per ICS-/webcal-Link
// (z. B. die "private Adresse im iCal-Format" von Google oder Outlook-Feeds).
type ICSConnConfig struct {
URL string `json:"url"`
}
type ICSProvider struct {
URL string
// DefaultTZ wird für "floating" Zeiten (ohne TZID/UTC) genutzt,
// typischerweise die Zeitzone des Kalenderbesitzers.
DefaultTZ string
}
func NewICSProvider(cfg ICSConnConfig, defaultTZ string) *ICSProvider {
return &ICSProvider{URL: cfg.URL, DefaultTZ: defaultTZ}
}
type icsEvent struct {
start time.Time
hasStart bool
allDay bool
end time.Time
cancelled bool
transparent bool
}
func (p *ICSProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "WannPassts/1.0 (+calendar-sync)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("ICS-Link nicht abrufbar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ICS-Link antwortet mit HTTP %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, fmt.Errorf("ICS-Antwort nicht lesbar: %w", err)
}
defLoc := time.UTC
if p.DefaultTZ != "" {
if loc, err := time.LoadLocation(p.DefaultTZ); err == nil {
defLoc = loc
}
}
events := parseICS(string(raw), defLoc)
var out []Interval
for _, ev := range events {
if ev.cancelled || ev.transparent || !ev.hasStart {
continue
}
end := ev.end
if end.IsZero() || !end.After(ev.start) {
if ev.allDay {
end = ev.start.Add(24 * time.Hour)
} else {
continue
}
}
out = append(out, Interval{Start: ev.start, End: end})
}
return clampIntervals(out, from, to), nil
}
// parseICS liest VEVENTs aus ICS-Daten. Zeilen werden entfaltet (RFC 5545),
// Parameter wie TZID/VALUE=DATE ausgewertet. Serientermine werden als ihre
// Basis-Instanz übernommen ICS-Abos sind damit der "best effort"-Weg,
// CalDAV und Google expandieren Serien serverseitig korrekt.
func parseICS(input string, defLoc *time.Location) []icsEvent {
input = strings.ReplaceAll(input, "\r\n", "\n")
rawLines := strings.Split(input, "\n")
lines := make([]string, 0, len(rawLines))
for _, l := range rawLines {
if strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t") {
if len(lines) > 0 {
lines[len(lines)-1] += l[1:]
}
continue
}
lines = append(lines, strings.TrimSuffix(l, "\r"))
}
var events []icsEvent
var cur *icsEvent
for _, line := range lines {
upper := strings.ToUpper(strings.TrimSpace(line))
switch {
case upper == "BEGIN:VEVENT":
cur = &icsEvent{}
continue
case upper == "END:VEVENT":
if cur != nil {
events = append(events, *cur)
cur = nil
}
continue
}
if cur == nil {
continue
}
name, params, value := splitICSLine(line)
switch name {
case "DTSTART":
if t, allDay, ok := parseICSDateTime(value, params, defLoc); ok {
cur.start, cur.hasStart, cur.allDay = t, true, allDay
}
case "DTEND":
if t, _, ok := parseICSDateTime(value, params, defLoc); ok {
cur.end = t
}
case "DURATION":
if cur.hasStart {
if d, ok := parseISODuration(value); ok {
cur.end = cur.start.Add(d)
}
}
case "STATUS":
if strings.EqualFold(value, "CANCELLED") {
cur.cancelled = true
}
case "TRANSP":
if strings.EqualFold(value, "TRANSPARENT") {
cur.transparent = true
}
}
}
return events
}
// splitICSLine zerlegt "NAME;PARAM=WERT;...:value" am ersten Doppelpunkt.
func splitICSLine(line string) (name string, params map[string]string, value string) {
params = map[string]string{}
i := strings.IndexByte(line, ':')
if i == -1 {
return strings.ToUpper(strings.TrimSpace(line)), params, ""
}
head, value := line[:i], strings.TrimSpace(line[i+1:])
parts := strings.Split(head, ";")
name = strings.ToUpper(strings.TrimSpace(parts[0]))
for _, p := range parts[1:] {
if eq := strings.IndexByte(p, '='); eq > 0 {
key := strings.ToUpper(strings.TrimSpace(p[:eq]))
params[key] = strings.Trim(strings.TrimSpace(p[eq+1:]), `"`)
}
}
return name, params, value
}
func parseICSDateTime(value string, params map[string]string, defLoc *time.Location) (t time.Time, allDay, ok bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false, false
}
if params["VALUE"] == "DATE" || (len(value) == 8 && allDigits(value)) {
y, err1 := strconv.Atoi(value[0:4])
m, err2 := strconv.Atoi(value[4:6])
d, err3 := strconv.Atoi(value[6:8])
if err1 != nil || err2 != nil || err3 != nil {
return time.Time{}, false, false
}
return time.Date(y, time.Month(m), d, 0, 0, 0, 0, defLoc), true, true
}
layouts := []struct {
layout string
utc bool
}{
{"20060102T150405Z", true},
{"20060102T150405", false},
{"2006-01-02T15:04:05Z07:00", true},
{"2006-01-02T15:04:05", false},
}
for _, l := range layouts {
if parsed, err := time.Parse(l.layout, value); err == nil {
if l.utc {
return parsed.UTC(), false, true
}
// Wanduhrzeit in der Zielzone interpretieren (nicht instant-konvertieren)
loc := defLoc
if tzid := params["TZID"]; tzid != "" {
if loaded, err := time.LoadLocation(tzid); err == nil {
loc = loaded
}
}
y, mo, d := parsed.Date()
hh, mm, ss := parsed.Clock()
return time.Date(y, mo, d, hh, mm, ss, 0, loc), false, true
}
}
return time.Time{}, false, false
}
func allDigits(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
// parseISODuration versteht die üblichen ICS-Dauerformen: PT15M, PT1H30M,
// P1D, P2W, PT45S …
func parseISODuration(s string) (time.Duration, bool) {
s = strings.TrimSpace(strings.ToUpper(s))
if len(s) < 2 || s[0] != 'P' || strings.Contains(s, "-") {
return 0, false
}
body := s[1:]
if strings.HasSuffix(body, "W") {
v, err := strconv.ParseFloat(strings.TrimSuffix(body, "W"), 64)
if err != nil {
return 0, false
}
return time.Duration(v * float64(7*24*time.Hour)), true
}
if strings.HasPrefix(body, "T") {
body = body[1:]
}
units := map[byte]time.Duration{
'D': 24 * time.Hour,
'H': time.Hour,
'M': time.Minute,
'S': time.Second,
}
var total time.Duration
num := ""
flush := func(unit time.Duration) bool {
if num == "" {
return false
}
v, err := strconv.ParseFloat(num, 64)
if err != nil {
return false
}
total += time.Duration(v * float64(unit))
num = ""
return true
}
for i := 0; i < len(body); i++ {
c := body[i]
if (c >= '0' && c <= '9') || c == '.' {
num += string(c)
continue
}
unit, known := units[c]
if !known || !flush(unit) {
return 0, false
}
}
return total, num == ""
}

View file

@ -0,0 +1,145 @@
package calendar
import (
"testing"
"time"
)
const sampleICS = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//EN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:STANDARD
DTSTART:19701025T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:1
DTSTART;TZID=Europe/Berlin:20260825T090000
DTEND;TZID=Europe/Berlin:20260825T100000
SUMMARY:Zahnarzt
END:VEVENT
BEGIN:VEVENT
UID:2
DTSTART:20260826T120000Z
DURATION:PT45M
SUMMARY:Call
END:VEVENT
BEGIN:VEVENT
UID:3
DTSTART;VALUE=DATE:20260827
SUMMARY:Ganztägig
END:VEVENT
BEGIN:VEVENT
UID:4
DTSTART:20260828T080000Z
DTEND:20260828T090000Z
STATUS:CANCELLED
SUMMARY:Abgesagt
END:VEVENT
BEGIN:VEVENT
UID:5
DTSTART:20260828T100000Z
DTEND:20260828T110000Z
TRANSP:TRANSPARENT
SUMMARY:Verfügbar
END:VEVENT
BEGIN:VEVENT
UID:6
DTSTART:20260829T080000Z
DTEND:202609
01T080000Z
SUMMARY:Gefaltet
END:VEVENT
END:VCALENDAR
`
func TestParseICS(t *testing.T) {
berlin, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Fatal(err)
}
events := parseICS(sampleICS, berlin)
if len(events) != 6 {
t.Fatalf("6 Events erwartet, got %d", len(events))
}
// TZID-Event
if !events[0].start.Equal(time.Date(2026, 8, 25, 9, 0, 0, 0, berlin)) {
t.Errorf("TZID-Start falsch: %v", events[0].start)
}
if !events[0].end.Equal(time.Date(2026, 8, 25, 10, 0, 0, 0, berlin)) {
t.Errorf("TZID-Ende falsch: %v", events[0].end)
}
// UTC + DURATION
if !events[1].end.Equal(events[1].start.Add(45*time.Minute)) {
t.Errorf("DURATION nicht angewandt: %v", events[1].end)
}
// Ganztägig
if !events[2].allDay {
t.Error("VALUE=DATE nicht erkannt")
}
// Cancelled / Transparent markiert
if !events[3].cancelled {
t.Error("CANCELLED nicht erkannt")
}
if !events[4].transparent {
t.Error("TRANSPARENT nicht erkannt")
}
// Gefaltete Zeile (DTEND über zwei Zeilen)
if !events[5].end.Equal(time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)) {
t.Errorf("Folding nicht aufgelöst, Ende: %v", events[5].end)
}
}
func TestParseISODuration(t *testing.T) {
cases := map[string]time.Duration{
"PT15M": 15 * time.Minute,
"PT1H30M": 90 * time.Minute,
"P1D": 24 * time.Hour,
"P2W": 14 * 24 * time.Hour,
"PT45S": 45 * time.Second,
"": 0,
"X": 0,
"P-1D": 0,
}
for in, want := range cases {
got, ok := parseISODuration(in)
if in == "" || in == "X" || in == "P-1D" {
if ok {
t.Errorf("%q sollte ungültig sein", in)
}
continue
}
if !ok || got != want {
t.Errorf("parseISODuration(%q) = %v (ok=%v), want %v", in, got, ok, want)
}
}
}
func TestClampIntervals(t *testing.T) {
from := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC)
to := from.Add(48 * time.Hour)
in := []Interval{
{Start: from.Add(-2 * time.Hour), End: from.Add(time.Hour)},
{Start: from.Add(3 * time.Hour), End: from.Add(2 * time.Hour)}, // ungültig
{Start: from.Add(10 * time.Hour), End: to.Add(time.Hour)},
}
out := clampIntervals(in, from, to)
if len(out) != 2 {
t.Fatalf("2 Intervalle erwartet, got %d", len(out))
}
if !out[0].Start.Equal(from) || !out[0].End.Equal(from.Add(time.Hour)) {
t.Errorf("Intervall 1 nicht geklemmt: %v%v", out[0].Start, out[0].End)
}
if !out[1].End.Equal(to) {
t.Errorf("Intervall 2 nicht geklemmt: %v", out[1].End)
}
}

View file

@ -0,0 +1,41 @@
package calendar
import (
"context"
"sort"
"time"
)
// Interval ist ein "beschäftigt"-Zeitraum. Enthält bewusst KEINE Termindetails,
// nur Start und Ende mehr verlässt den Kalender des Nutzers nie.
type Interval struct {
Start time.Time
End time.Time
}
type Provider interface {
// FetchBusy liefert alle busy-Zeiträume im Fenster [from, to).
FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error)
}
// clampIntervals schneidet Intervalle aufs Fenster zurecht, verwirft ungültige
// und sortiert das Ergebnis.
func clampIntervals(in []Interval, from, to time.Time) []Interval {
out := make([]Interval, 0, len(in))
for _, iv := range in {
if !iv.End.After(iv.Start) {
continue
}
if iv.Start.Before(from) {
iv.Start = from
}
if iv.End.After(to) {
iv.End = to
}
if iv.End.After(iv.Start) {
out = append(out, iv)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
return out
}

View file

@ -0,0 +1,139 @@
package calendar
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"golang.org/x/oauth2"
"wannpassts/internal/config"
"wannpassts/internal/crypto"
"wannpassts/internal/db"
)
const syncStaleAfter = 5 * time.Minute
type Syncer struct {
DB *db.DB
Cfg *config.Config
}
// NeedsSync meldet, ob eine der Verbindungen älter als syncStaleAfter ist.
func NeedsSync(conns []*db.Connection, now time.Time) bool {
for _, c := range conns {
if !c.LastSyncedTs.Valid || now.Unix()-c.LastSyncedTs.Int64 > int64(syncStaleAfter.Seconds()) {
return true
}
}
return false
}
// SyncUser aktualisiert die busy-Zeiträume aller Verbindungen eines Nutzers
// für das Buchungsfenster [jetzt, jetzt+horizon]. Fehler einzelner Provider
// werden pro Verbindung gespeichert und werfen den Gesamtsync nicht ab.
func (s *Syncer) SyncUser(ctx context.Context, user *db.User) {
conns, err := s.DB.ConnectionsForUser(user.ID)
if err != nil {
log.Printf("sync: Verbindungen von User %d nicht lesbar: %v", user.ID, err)
return
}
if len(conns) == 0 {
return
}
from := time.Now().Add(-time.Hour)
to := time.Now().Add(time.Duration(user.HorizonDays) * 24 * time.Hour)
for _, conn := range conns {
s.syncConnection(ctx, conn, user, from, to)
}
}
func (s *Syncer) syncConnection(ctx context.Context, conn *db.Connection, user *db.User, from, to time.Time) {
prov, err := s.provider(conn)
if err != nil {
_ = s.DB.SetConnectionState(conn.ID, false, err.Error())
return
}
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
busy, err := prov.FetchBusy(cctx, from, to)
if err != nil {
_ = s.DB.SetConnectionState(conn.ID, false, cleanErr(err.Error()))
return
}
slots := make([]db.BusySlot, 0, len(busy))
for _, iv := range busy {
slots = append(slots, db.BusySlot{StartTs: iv.Start.Unix(), EndTs: iv.End.Unix()})
}
if err := s.DB.ReplaceBusyForConnection(conn.ID, user.ID, slots); err != nil {
_ = s.DB.SetConnectionState(conn.ID, false, "Speichern fehlgeschlagen: "+err.Error())
return
}
_ = s.DB.SetConnectionState(conn.ID, true, "")
}
// SyncInBackground startet den Sync ohne den Aufrufer zu blocken.
func (s *Syncer) SyncInBackground(user *db.User) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
s.SyncUser(ctx, user)
}()
}
func (s *Syncer) provider(conn *db.Connection) (Provider, error) {
plain, err := crypto.Decrypt(s.Cfg.EncryptionKey, conn.ConfigEnc)
if err != nil {
return nil, fmt.Errorf("gespeicherte Verbindung nicht entschlüsselbar (ENCRYPTION_KEY geändert?): %w", err)
}
switch conn.Provider {
case "google":
var cfg GoogleConnConfig
if err := json.Unmarshal(plain, &cfg); err != nil {
return nil, fmt.Errorf("Google-Konfiguration unlesbar: %w", err)
}
oconf := GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
s.Cfg.AppURL+"/api/calendars/google/callback")
calendarID := cfg.CalendarID
return NewGoogleProvider(oconf, cfg, func(t *oauth2.Token) {
s.saveGoogleToken(conn.ID, calendarID, t)
}), nil
case "caldav":
var cfg CalDAVConnConfig
if err := json.Unmarshal(plain, &cfg); err != nil {
return nil, fmt.Errorf("CalDAV-Konfiguration unlesbar: %w", err)
}
return NewCalDAVProvider(cfg), nil
case "ics":
var cfg ICSConnConfig
if err := json.Unmarshal(plain, &cfg); err != nil {
return nil, fmt.Errorf("ICS-Konfiguration unlesbar: %w", err)
}
return NewICSProvider(cfg, ""), nil
}
return nil, fmt.Errorf("unbekannter Provider %q", conn.Provider)
}
func (s *Syncer) saveGoogleToken(connID int64, calendarID string, t *oauth2.Token) {
data, err := json.Marshal(GoogleConnConfig{Token: t, CalendarID: calendarID})
if err != nil {
return
}
enc, err := crypto.Encrypt(s.Cfg.EncryptionKey, data)
if err != nil {
return
}
_ = s.DB.UpdateConnectionConfig(connID, enc)
}
func cleanErr(msg string) string {
msg = strings.TrimSpace(msg)
if len(msg) > 300 {
msg = msg[:300] + "…"
}
return msg
}