wannpassts/backend/internal/httpapi/server.go
Tronax cbc03c56bf
feat: add PWA support and mobile-responsive layouts
- Add web app manifest, maskable/apple-touch icons, and a production-only
  service worker that caches the app shell for offline start
- Improve mobile UX: responsive breakpoints from ~320px, 44px touch
  targets, safe-area insets, dvh heights, 16px inputs to avoid iOS zoom
- Serve .webmanifest with correct content type in the Go static handler
- Document PWA behavior and mobile features in the README
2026-08-25 19:12:12 +02:00

283 lines
7.8 KiB
Go

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 strings.HasSuffix(p, ".webmanifest") {
w.Header().Set("Content-Type", "application/manifest+json")
}
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,
}
}