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
139 lines
3.9 KiB
Go
139 lines
3.9 KiB
Go
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
|
|
}
|