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