wannpassts/backend/internal/httpapi/calendars.go
Tronax cb30223fd4
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
2026-08-25 18:50:28 +02:00

247 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/oauth2"
"wannpassts/internal/calendar"
"wannpassts/internal/crypto"
)
func (s *Server) ListCalendars(w http.ResponseWriter, r *http.Request) {
conns, err := s.DB.ConnectionsForUser(userFrom(r).ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Verbindungen nicht lesbar")
return
}
out := make([]ConnectionJSON, 0, len(conns))
for _, c := range conns {
out = append(out, connToJSON(c))
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) DeleteCalendar(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Ungültige ID")
return
}
if err := s.DB.DeleteConnection(id, userFrom(r).ID); err != nil {
jsonError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, nil)
}
func (s *Server) ConnectCalDAV(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
var in struct {
ServerURL string `json:"server_url"`
Username string `json:"username"`
Password string `json:"password"`
CalendarPath string `json:"calendar_path"`
DisplayName string `json:"display_name"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
in.ServerURL = strings.TrimSuffix(strings.TrimSpace(in.ServerURL), "/")
if !strings.HasPrefix(in.ServerURL, "http://") && !strings.HasPrefix(in.ServerURL, "https://") {
jsonError(w, http.StatusBadRequest, "Server-URL muss mit http:// oder https:// beginnen")
return
}
if in.Username == "" || in.Password == "" {
jsonError(w, http.StatusBadRequest, "Benutzername und Passwort werden benötigt (iCloud: App-spezifisches Passwort)")
return
}
cfg := calendar.CalDAVConnConfig{
ServerURL: in.ServerURL,
Username: in.Username,
Password: in.Password,
CalendarPath: strings.TrimSpace(in.CalendarPath),
}
if cfg.CalendarPath == "" {
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
path, name, err := calendar.DiscoverCalDAV(ctx, cfg)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
cfg.CalendarPath = path
if in.DisplayName == "" {
in.DisplayName = name
}
}
if in.DisplayName == "" {
if u, err := url.Parse(in.ServerURL); err == nil {
in.DisplayName = u.Host
} else {
in.DisplayName = "CalDAV-Kalender"
}
}
enc, err := s.encryptConfig(cfg)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
id, err := s.DB.CreateConnection(user.ID, "caldav", in.DisplayName, enc)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
return
}
s.Syncer.SyncInBackground(user)
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "caldav", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
}
func (s *Server) ConnectICS(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
var in struct {
URL string `json:"url"`
DisplayName string `json:"display_name"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
rawURL := strings.TrimSpace(in.URL)
if strings.HasPrefix(rawURL, "webcal://") {
rawURL = "https://" + strings.TrimPrefix(rawURL, "webcal://")
}
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
jsonError(w, http.StatusBadRequest, "Der Link muss mit http://, https:// oder webcal:// beginnen")
return
}
// Einmal direkt abrufen, um den Link zu validieren.
prov := calendar.NewICSProvider(calendar.ICSConnConfig{URL: rawURL}, user.Timezone)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
if _, err := prov.FetchBusy(ctx, time.Now().Add(-time.Hour), time.Now().Add(24*time.Hour)); err != nil {
jsonError(w, http.StatusBadRequest, "ICS-Link prüfen: "+err.Error())
return
}
if in.DisplayName == "" {
if u, err := url.Parse(rawURL); err == nil {
in.DisplayName = u.Host
} else {
in.DisplayName = "ICS-Kalender"
}
}
enc, err := s.encryptConfig(calendar.ICSConnConfig{URL: rawURL})
if err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
id, err := s.DB.CreateConnection(user.ID, "ics", in.DisplayName, enc)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Verbindung konnte nicht gespeichert werden")
return
}
s.Syncer.SyncInBackground(user)
writeJSON(w, http.StatusCreated, ConnectionJSON{ID: id, Provider: "ics", DisplayName: in.DisplayName, CreatedTs: time.Now().Unix()})
}
func (s *Server) GoogleConnectStart(w http.ResponseWriter, r *http.Request) {
if s.Cfg.GoogleClientID == "" || s.Cfg.GoogleClientSecret == "" {
jsonError(w, http.StatusServiceUnavailable, "Google-Anmeldung ist nicht konfiguriert (GOOGLE_CLIENT_ID und GOOGLE_CLIENT_SECRET in .env setzen)")
return
}
state, err := s.signJWT(userFrom(r).ID, 10*time.Minute)
if err != nil {
jsonError(w, http.StatusInternalServerError, "State konnte nicht erzeugt werden")
return
}
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
s.Cfg.AppURL+"/api/calendars/google/callback")
authURL := conf.AuthCodeURL(state,
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("prompt", "consent select_account"),
)
writeJSON(w, http.StatusOK, map[string]string{"url": authURL})
}
func (s *Server) GoogleConnectCallback(w http.ResponseWriter, r *http.Request) {
fail := func(reason string) {
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=error&reason="+url.QueryEscape(reason), http.StatusFound)
}
q := r.URL.Query()
if reason := q.Get("error"); reason != "" {
fail("Google-Anmeldung abgelehnt: " + reason)
return
}
uid, err := s.verifyJWT(q.Get("state"))
if err != nil {
fail("Ungültiger or abgelaufener Verbindungsversuch bitte erneut versuchen")
return
}
user, err := s.DB.UserByID(uid)
if err != nil {
fail("Konto nicht gefunden")
return
}
conf := calendar.GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret,
s.Cfg.AppURL+"/api/calendars/google/callback")
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
tok, err := conf.Exchange(ctx, q.Get("code"))
if err != nil {
fail("Google-Token-Austausch fehlgeschlagen: "+cleanReason(err.Error()))
return
}
calID, summary, err := calendar.GooglePrimaryCalendar(ctx, conf.Client(ctx, tok))
if err != nil {
fail("Google-Kalender nicht lesbar: "+cleanReason(err.Error()))
return
}
name := summary
if name == "" {
name = "Google Kalender"
}
enc, err := s.encryptConfig(calendar.GoogleConnConfig{Token: tok, CalendarID: calID})
if err != nil {
fail("Speichern fehlgeschlagen")
return
}
if _, err := s.DB.CreateConnection(user.ID, "google", name, enc); err != nil {
fail("Verbindung konnte nicht gespeichert werden")
return
}
s.Syncer.SyncInBackground(user)
http.Redirect(w, r, s.Cfg.FrontendURL+"/app?google=ok", http.StatusFound)
}
func (s *Server) SyncNow(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx, cancel := context.WithTimeout(context.Background(), 75*time.Second)
defer cancel()
s.Syncer.SyncUser(ctx, user)
s.ListCalendars(w, r)
}
func (s *Server) encryptConfig(cfg any) (string, error) {
data, err := json.Marshal(cfg)
if err != nil {
return "", err
}
return crypto.Encrypt(s.Cfg.EncryptionKey, data)
}
func cleanReason(msg string) string {
msg = strings.TrimSpace(msg)
if len(msg) > 200 {
msg = msg[:200] + "…"
}
return msg
}