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
289
backend/internal/httpapi/auth.go
Normal file
289
backend/internal/httpapi/auth.go
Normal 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 8–200 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 1–80 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 5–180 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 1–120 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("1–8 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 5–480 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue