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