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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue