Files
Crussell/backend/main.go
T
popertots bff86a6660 feat: 10 quick wins — account deletion, health check, debug cleanup, UX polish, graceful shutdown
- backend/handlers/user/account.go: Wire DELETE /api/user/account to call
  anonymize_user() for registered users and delete_guest_user() for guests,
  with CardDAV contact cleanup
- backend/handlers/user/profile_test.go: Add TestAccount_DeleteGuest and
  enhance TestAccount_Delete to verify anonymization results
- backend/main.go: Add GET /api/health endpoint with DB ping and S3 status
  check; add HSTS and Referrer-Policy security headers; replace
  http.ListenAndServe with http.Server + graceful SIGTERM/SIGINT shutdown
- frontend/routes/+layout.svelte: Replace alert() with toast notifications
  for email verification flow
- frontend/routes/login/+page.svelte: Replace alert() with toast.info for
  social login prototype buttons
- frontend/booking/BookingFlow.svelte: Remove 2 console.log debug calls;
  add cancellation policy note in Step 3; add timezone policy comment
- frontend/ImageUpload.svelte: Comment out debug console.log
- init-scripts/init-script.sql: Add delete_guest_user() SQL function
- docs: Update README.md and Obsidian notes to reflect completed items
2026-05-01 11:33:27 +01:00

298 lines
9.1 KiB
Go

package main
import (
"context"
"crussell/auth"
"crussell/internal/dav"
"crussell/internal/s3"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"crussell/db"
"crussell/mw"
authHandlers "crussell/handlers/auth"
"crussell/handlers/bookings"
"crussell/handlers/notifications"
"crussell/handlers/portfolio"
"crussell/handlers/scheduling"
"crussell/handlers/services"
"crussell/handlers/today"
"crussell/handlers/user"
)
func init() {
jwtSecret := os.Getenv("JWT_SECRET_KEY")
if jwtSecret == "" {
log.Fatal("FATAL: JWT_SECRET_KEY environment variable not set. Application cannot start.")
}
auth.InitJWT(jwtSecret)
}
func initDB() {
if err := db.Connect(); err != nil {
log.Fatal("Failed to connect to DB:", err)
}
fmt.Println("Connected to DB successfully")
}
func initDav() {
if dav.Service == nil {
log.Fatal("Failed to initialize DAV service")
}
fmt.Println("DAV Service connected successfully")
}
func initS3() {
if err := s3.Connect(); err != nil {
log.Printf("WARNING: Failed to connect to S3: %v", err)
} else {
fmt.Println("S3 client initialized")
}
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
status := "ok"
services := map[string]string{
"backend": "ok",
"database": "ok",
"s3_storage": "ok",
"square_payments": "not_implemented",
"frontend": "unknown",
}
if db.DB != nil {
if err := db.DB.Ping(r.Context()); err != nil {
services["database"] = "error"
status = "degraded"
}
} else {
services["database"] = "error"
status = "degraded"
}
if s3.Client == nil {
services["s3_storage"] = "not_configured"
}
w.Header().Set("Content-Type", "application/json")
if status == "degraded" {
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"status": status,
"services": services,
})
}
func main() {
initDB()
initDav()
initS3()
r := chi.NewRouter()
// --- Global Middleware ---
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(15 * time.Second))
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
// TODO: Enable HSTS in production
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// TODO: Enable Referrer-Policy in production
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
})
// All API routes grouped under /api for clarity
r.Route("/api", func(r chi.Router) {
// Public read-only (but check auth context if present for eligibility)
r.Group(func(r chi.Router) {
r.Use(mw.RateLimit(120, time.Minute))
r.Get("/services", services.ServicesHandler)
r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler)
})
// Registration: 10/min to prevent spam
r.With(mw.RateLimit(10, time.Minute)).Post("/register", authHandlers.RegisterHandler)
// Login: Has its own internal rate limiting
r.Post("/login", authHandlers.LoginHandler)
// Email verification
r.Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler)
r.Post("/verify/check", authHandlers.VerifyCodeHandler)
// Health check
r.Get("/health", healthCheckHandler)
// Public contact info
r.Get("/contact", user.GetContactInfoHandler)
// Portfolio
r.Route("/portfolio", func(r chi.Router) {
r.Get("/images", portfolio.ListImages)
r.Get("/tags", portfolio.ListTags)
r.With(mw.RateLimit(60, time.Minute)).Get("/filters", portfolio.ListFilters)
r.With(mw.RateLimit(120, time.Minute)).Get("/images/{id}", portfolio.GetImage)
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin)
r.Use(mw.RateLimit(60, time.Minute))
r.Post("/images", portfolio.UploadImage)
r.Delete("/images/{id}", portfolio.DeleteImage)
})
})
// Scheduling
r.Route("/scheduling", func(r chi.Router) {
r.Use(mw.RateLimit(120, time.Minute))
r.Get("/default-hours", scheduling.GetDefaultHours)
r.Get("/exceptional-groups", scheduling.ListExceptionalGroups)
r.Get("/working-hours", scheduling.GetWorkingHours)
r.Get("/available-hours", scheduling.GetAvailableHours)
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin)
r.Use(mw.RateLimit(60, time.Minute))
r.Put("/default-hours", scheduling.UpdateDefaultHours)
r.Post("/exceptional-groups", scheduling.CreateExceptionalGroup)
r.Delete("/exceptional-groups", scheduling.DeleteExceptionalGroup)
r.Put("/exceptional-applications", scheduling.UpdateExceptionalApplications)
})
})
// Public booking endpoints (optional auth for slot reservation and guest bookings)
r.Group(func(r chi.Router) {
r.Use(mw.RateLimit(30, time.Minute), mw.OptionalAuth)
r.Post("/bookings/reserve", bookings.ReserveSlotHandler)
r.Post("/bookings", bookings.CreateBookingHandler)
})
// Guest user creation (public, no auth required)
r.With(mw.RateLimit(10, time.Minute)).Post("/users/guest", user.CreateGuestUserHandler)
// Authenticated users
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RateLimit(120, time.Minute))
r.Post("/refresh-token", authHandlers.RefreshTokenHandler)
r.Get("/user/profile", user.GetProfileHandler)
r.Put("/user/profile", user.UpdateProfileHandler)
r.Post("/user/profile-picture", user.UploadProfilePictureHandler)
r.Put("/user/change-password", user.ChangePasswordHandler)
r.Delete("/user/account", user.DeleteAccountHandler)
r.Get("/user/loyalty", user.GetLoyaltyHandler)
r.Get("/bookings", bookings.GetAllUserBookingsHandler)
r.Get("/bookings/{id}", bookings.GetBookingHandler)
r.Get("/bookings/{id}/calendar", bookings.GetBookingCalendarHandler)
r.Put("/bookings/{id}", bookings.EditBookingHandler)
r.Delete("/bookings/{id}", bookings.DeleteBookingHandler)
r.Post("/bookings/{id}/edit-request", bookings.RequestEditHandler)
r.Delete("/bookings/{id}/edit-request", bookings.DeleteEditRequestHandler)
})
// Admin-only (no rate limit - trusted users with authenticated sessions)
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RequireAdmin)
r.Route("/admin/services", func(r chi.Router) {
r.Post("/", services.CreateServiceHandler)
r.Delete("/{id}", services.DeleteServiceHandler)
r.Get("/", services.AllServicesHandler)
r.Put("/{id}/toggle", services.ToggleService)
})
r.Route("/admin/bookings", func(r chi.Router) {
r.Get("/", bookings.GetAllAdminBookingsHandler)
r.Post("/", bookings.AdminCreateBookingForUserHandler)
r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler)
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
r.Get("/{id}", bookings.GetAdminBookingHandler)
r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler)
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
r.Post("/{id}/cancel", bookings.AdminCancelBookingHandler)
r.Post("/reserve", bookings.AdminReserveSlotHandler)
// Edit request endpoints
r.Get("/{id}/edit-requests", bookings.AdminListEditRequestsHandler)
r.Post("/{id}/edit-requests/{request_id}/approve", bookings.AdminApproveEditRequestHandler)
r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler)
})
r.Route("/admin/users", func(r chi.Router) {
r.Get("/", user.ListAdminUsersHandler)
r.Get("/{id}", user.GetAdminUserHandler)
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
})
r.Route("/admin/today", func(r chi.Router) {
r.Get("/current-next", today.GetCurrentAndNextHandler)
r.Get("/appointments", today.GetTodayAppointmentsHandler)
r.Get("/pending-approvals", today.GetPendingApprovalsHandler)
})
r.Route("/admin/notifications", func(r chi.Router) {
r.Get("/", notifications.GetNotifications)
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
})
r.Route("/admin/time-blockers", func(r chi.Router) {
r.Get("/", scheduling.ListTimeBlockers)
r.Post("/", scheduling.CreateTimeBlocker)
r.Delete("/{id}", scheduling.DeleteTimeBlocker)
})
})
})
srv := &http.Server{
Addr: ":8080",
Handler: r,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
}()
fmt.Println("Server is listening on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
}
log.Println("Server exited")
}