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
247 lines
7.5 KiB
Go
247 lines
7.5 KiB
Go
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
|
||
}
|