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
247
backend/internal/httpapi/calendars.go
Normal file
247
backend/internal/httpapi/calendars.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue