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
139
backend/internal/calendar/sync.go
Normal file
139
backend/internal/calendar/sync.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue