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
280
backend/internal/httpapi/server.go
Normal file
280
backend/internal/httpapi/server.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"wannpassts/internal/calendar"
|
||||
"wannpassts/internal/config"
|
||||
"wannpassts/internal/db"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
DB *db.DB
|
||||
Cfg *config.Config
|
||||
Syncer *calendar.Syncer
|
||||
}
|
||||
|
||||
func New(database *db.DB, cfg *config.Config, syncer *calendar.Syncer) *Server {
|
||||
return &Server{DB: database, Cfg: cfg, Syncer: syncer}
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const ctxUser ctxKey = 1
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins(s.Cfg.FrontendURL),
|
||||
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type"},
|
||||
MaxAge: 300,
|
||||
}))
|
||||
r.Get("/api/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Post("/auth/register", s.Register)
|
||||
api.Post("/auth/login", s.Login)
|
||||
|
||||
api.Get("/calendars/google/callback", s.GoogleConnectCallback)
|
||||
api.Get("/public/{slug}", s.PublicInfo)
|
||||
api.Post("/public/{slug}/bookings", s.CreateBooking)
|
||||
|
||||
api.Group(func(prt chi.Router) {
|
||||
prt.Use(s.AuthMiddleware)
|
||||
prt.Get("/me", s.Me)
|
||||
prt.Patch("/me", s.UpdateMe)
|
||||
prt.Post("/me/slug", s.RegenSlug)
|
||||
prt.Get("/calendars", s.ListCalendars)
|
||||
prt.Delete("/calendars/{id}", s.DeleteCalendar)
|
||||
prt.Post("/calendars/caldav", s.ConnectCalDAV)
|
||||
prt.Post("/calendars/ics", s.ConnectICS)
|
||||
prt.Get("/calendars/google/start", s.GoogleConnectStart)
|
||||
prt.Post("/calendars/sync", s.SyncNow)
|
||||
prt.Get("/bookings", s.ListBookings)
|
||||
prt.Post("/bookings/{id}/accept", s.AcceptBooking)
|
||||
prt.Post("/bookings/{id}/decline", s.DeclineBooking)
|
||||
})
|
||||
})
|
||||
|
||||
if s.Cfg.StaticDir != "" {
|
||||
if st, err := os.Stat(s.Cfg.StaticDir); err == nil && st.IsDir() {
|
||||
static := s.Cfg.StaticDir
|
||||
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
|
||||
p := filepath.Join(static, filepath.Clean(req.URL.Path))
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
http.ServeFile(w, req, p)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, req, filepath.Join(static, "index.html"))
|
||||
})
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func allowedOrigins(frontendURL string) []string {
|
||||
origins := []string{"http://localhost:5173", "http://127.0.0.1:5173"}
|
||||
for _, o := range strings.Split(frontendURL, ",") {
|
||||
o = strings.TrimSpace(o)
|
||||
if o != "" && !contains(origins, o) {
|
||||
origins = append(origins, o)
|
||||
}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Auth Middleware & JWT ---
|
||||
|
||||
func (s *Server) signJWT(userID int64, ttl time.Duration) (string, error) {
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: strconv.FormatInt(userID, 10),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return tok.SignedString([]byte(s.Cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func (s *Server) verifyJWT(tokenStr string) (int64, error) {
|
||||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unerwartete Signaturmethode")
|
||||
}
|
||||
return []byte(s.Cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return 0, errors.New("Token ungültig oder abgelaufen")
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return 0, errors.New("Claims unlesbar")
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
id, err := strconv.ParseInt(sub, 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("Subject unlesbar")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, "Bearer ") {
|
||||
jsonError(w, http.StatusUnauthorized, "Nicht angemeldet")
|
||||
return
|
||||
}
|
||||
uid, err := s.verifyJWT(strings.TrimSpace(h[len("Bearer "):]))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "Sitzung ungültig oder abgelaufen")
|
||||
return
|
||||
}
|
||||
user, err := s.DB.UserByID(uid)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "Konto nicht gefunden")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, user)))
|
||||
})
|
||||
}
|
||||
|
||||
func userFrom(r *http.Request) *db.User {
|
||||
return r.Context().Value(ctxUser).(*db.User)
|
||||
}
|
||||
|
||||
// --- JSON helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func decodeBody(r *http.Request, v any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, error) {
|
||||
return strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
}
|
||||
|
||||
// --- JSON Darstellungen ---
|
||||
|
||||
type UserJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
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"`
|
||||
CreatedTs int64 `json:"created_ts"`
|
||||
}
|
||||
|
||||
func userToJSON(u *db.User) UserJSON {
|
||||
return UserJSON{
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, Slug: u.Slug, Timezone: u.Timezone,
|
||||
SlotMinutes: u.SlotMinutes, DayStartMin: u.DayStartMin, DayEndMin: u.DayEndMin,
|
||||
HorizonDays: u.HorizonDays, Durations: csvInts(u.Durations), Weekdays: csvInts(u.Weekdays),
|
||||
CreatedTs: u.CreatedTs,
|
||||
}
|
||||
}
|
||||
|
||||
func csvInts(csv string) []int {
|
||||
var out []int
|
||||
for _, p := range strings.Split(csv, ",") {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intsCSV(list []int) string {
|
||||
parts := make([]string, len(list))
|
||||
for i, v := range list {
|
||||
parts[i] = strconv.Itoa(v)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
type ConnectionJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LastSyncedTs *int64 `json:"last_synced_ts"`
|
||||
LastError *string `json:"last_error"`
|
||||
CreatedTs int64 `json:"created_ts"`
|
||||
}
|
||||
|
||||
func connToJSON(c *db.Connection) ConnectionJSON {
|
||||
out := ConnectionJSON{ID: c.ID, Provider: c.Provider, DisplayName: c.DisplayName, CreatedTs: c.CreatedTs}
|
||||
if c.LastSyncedTs.Valid {
|
||||
v := c.LastSyncedTs.Int64
|
||||
out.LastSyncedTs = &v
|
||||
}
|
||||
if c.LastError.Valid && c.LastError.String != "" {
|
||||
v := c.LastError.String
|
||||
out.LastError = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type BookingJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
RequesterName string `json:"requester_name"`
|
||||
RequesterEmail string `json:"requester_email"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
CreatedTs int64 `json:"created_ts"`
|
||||
}
|
||||
|
||||
func bookingToJSON(b *db.Booking) BookingJSON {
|
||||
return BookingJSON{
|
||||
ID: b.ID,
|
||||
Start: time.Unix(b.StartTs, 0).UTC().Format(time.RFC3339),
|
||||
End: time.Unix(b.EndTs, 0).UTC().Format(time.RFC3339),
|
||||
RequesterName: b.RequesterName, RequesterEmail: b.RequesterEmail,
|
||||
Message: b.Message, Status: b.Status, CreatedTs: b.CreatedTs,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue