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,289 @@
package httpapi
import (
"crypto/rand"
"encoding/base64"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"wannpassts/internal/db"
)
const tokenTTL = 7 * 24 * time.Hour
func (s *Server) Register(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Timezone string `json:"timezone"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
in.Name = strings.TrimSpace(in.Name)
if !validEmail(in.Email) {
jsonError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
return
}
if len(in.Password) < 8 || len(in.Password) > 200 {
jsonError(w, http.StatusBadRequest, "Das Passwort muss 8200 Zeichen lang sein")
return
}
if in.Name == "" || len(in.Name) > 80 {
jsonError(w, http.StatusBadRequest, "Bitte einen Namen (max. 80 Zeichen) angeben")
return
}
tz := in.Timezone
if tz == "" {
tz = "Europe/Berlin"
}
if _, err := time.LoadLocation(tz); err != nil {
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
return
}
if _, err := s.DB.UserByEmail(in.Email); err == nil {
jsonError(w, http.StatusConflict, "Diese E-Mail-Adresse ist bereits registriert")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 10)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
return
}
slug, err := s.uniqueSlug()
if err != nil {
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
return
}
u := &db.User{Email: in.Email, Name: in.Name, PasswordHash: string(hash), Slug: slug, Timezone: tz}
if err := s.DB.CreateUser(u); err != nil {
jsonError(w, http.StatusInternalServerError, "Konto konnte nicht erstellt werden")
return
}
token, err := s.signJWT(u.ID, tokenTTL)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": userToJSON(u)})
}
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
u, err := s.DB.UserByEmail(strings.ToLower(strings.TrimSpace(in.Email)))
if err != nil {
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Password)) != nil {
jsonError(w, http.StatusUnauthorized, "E-Mail oder Passwort falsch")
return
}
token, err := s.signJWT(u.ID, tokenTTL)
if err != nil {
jsonError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": userToJSON(u)})
}
func (s *Server) Me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(userFrom(r))})
}
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
u := userFrom(r)
var in struct {
Name *string `json:"name"`
Timezone *string `json:"timezone"`
SlotMinutes *int `json:"slot_minutes"`
DayStartMin *int `json:"day_start_min"`
DayEndMin *int `json:"day_end_min"`
HorizonDays *int `json:"horizon_days"`
Durations []int `json:"durations"`
Weekdays []int `json:"weekdays"`
}
if err := decodeBody(r, &in); err != nil {
jsonError(w, http.StatusBadRequest, "Anfrage unlesbar")
return
}
if in.Name != nil {
name := strings.TrimSpace(*in.Name)
if name == "" || len(name) > 80 {
jsonError(w, http.StatusBadRequest, "Name muss 180 Zeichen haben")
return
}
u.Name = name
}
if in.Timezone != nil {
if _, err := time.LoadLocation(*in.Timezone); err != nil {
jsonError(w, http.StatusBadRequest, "Unbekannte Zeitzone")
return
}
u.Timezone = *in.Timezone
}
if in.SlotMinutes != nil {
if *in.SlotMinutes < 5 || *in.SlotMinutes > 180 {
jsonError(w, http.StatusBadRequest, "Slot-Größe muss 5180 Minuten sein")
return
}
u.SlotMinutes = *in.SlotMinutes
}
if in.DayStartMin != nil {
if *in.DayStartMin < 0 || *in.DayStartMin > 1439 {
jsonError(w, http.StatusBadRequest, "Tagesbeginn ungültig")
return
}
u.DayStartMin = *in.DayStartMin
}
if in.DayEndMin != nil {
if *in.DayEndMin < 1 || *in.DayEndMin > 1440 {
jsonError(w, http.StatusBadRequest, "Tagesende ungültig")
return
}
u.DayEndMin = *in.DayEndMin
}
if u.DayStartMin >= u.DayEndMin {
jsonError(w, http.StatusBadRequest, "Tagesbeginn muss vor dem Tagesende liegen")
return
}
if in.HorizonDays != nil {
if *in.HorizonDays < 1 || *in.HorizonDays > 120 {
jsonError(w, http.StatusBadRequest, "Buchungshorizont muss 1120 Tage sein")
return
}
u.HorizonDays = *in.HorizonDays
}
if in.Durations != nil {
durations, err := cleanDurations(in.Durations)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
u.Durations = intsCSV(durations)
}
if in.Weekdays != nil {
weekdays, err := cleanWeekdays(in.Weekdays)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
u.Weekdays = intsCSV(weekdays)
}
if err := s.DB.UpdateUserSettings(u); err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
}
func (s *Server) RegenSlug(w http.ResponseWriter, r *http.Request) {
u := userFrom(r)
slug, err := s.uniqueSlug()
if err != nil {
jsonError(w, http.StatusInternalServerError, "Interner Fehler")
return
}
if err := s.DB.UpdateUserSlug(u.ID, slug); err != nil {
jsonError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
u.Slug = slug
writeJSON(w, http.StatusOK, map[string]any{"user": userToJSON(u)})
}
func (s *Server) uniqueSlug() (string, error) {
for i := 0; i < 8; i++ {
b := make([]byte, 9)
if _, err := rand.Read(b); err != nil {
return "", err
}
slug := base64.RawURLEncoding.EncodeToString(b)
exists, err := s.DB.SlugExists(slug)
if err != nil {
return "", err
}
if !exists {
return slug, nil
}
}
return "", http.ErrBodyNotAllowed
}
func validEmail(s string) bool {
if len(s) < 5 || len(s) > 254 || strings.Count(s, "@") != 1 {
return false
}
at := strings.IndexByte(s, '@')
local, domain := s[:at], s[at+1:]
if local == "" || !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
return !strings.ContainsAny(s, " \t")
}
func cleanDurations(list []int) ([]int, error) {
if len(list) == 0 || len(list) > 8 {
return nil, errNew("18 Dauerangaben benötigt")
}
seen := map[int]bool{}
var out []int
for _, v := range list {
if v < 5 || v > 480 {
return nil, errNew("Dauern müssen 5480 Minuten sein")
}
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out, nil
}
func cleanWeekdays(list []int) ([]int, error) {
if len(list) == 0 || len(list) > 7 {
return nil, errNew("Mindestens ein Buchungstag nötig")
}
seen := map[int]bool{}
var out []int
for _, v := range list {
if v < 1 || v > 7 {
return nil, errNew("Wochentage müssen 1 (Mo) 7 (So) sein")
}
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out, nil
}
func errNew(msg string) error { return &simpleError{msg} }
type simpleError struct{ msg string }
func (e *simpleError) Error() string { return e.msg }