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
151 lines
4.4 KiB
Go
151 lines
4.4 KiB
Go
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
|
||
}
|