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
|
||||
}
|
||||
151
backend/internal/calendar/google.go
Normal file
151
backend/internal/calendar/google.go
Normal 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
|
||||
}
|
||||
270
backend/internal/calendar/ics.go
Normal file
270
backend/internal/calendar/ics.go
Normal 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 == ""
|
||||
}
|
||||
145
backend/internal/calendar/ics_test.go
Normal file
145
backend/internal/calendar/ics_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
41
backend/internal/calendar/provider.go
Normal file
41
backend/internal/calendar/provider.go
Normal 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
|
||||
}
|
||||
139
backend/internal/calendar/sync.go
Normal file
139
backend/internal/calendar/sync.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue