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
289 lines
7.8 KiB
Go
289 lines
7.8 KiB
Go
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 }
|