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:
Tronax 2026-08-25 18:50:28 +02:00
commit cb30223fd4
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
47 changed files with 6599 additions and 0 deletions

View 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
}