wannpassts/backend/internal/calendar/google.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

151 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}