package server import ( "context" "embed" "errors" "fmt" "io/fs" "log/slog" "net/http" "os" "os/signal" "time" sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/rjNemo/rentease/internal/config" "github.com/rjNemo/rentease/internal/service/auth" "github.com/rjNemo/rentease/internal/service/booking" ) type Server struct { Router *chi.Mux httpServer *http.Server bs *booking.Service as *auth.Service hc *config.Host addr string stripeWebhookSecret string } func New(bs *booking.Service, as *auth.Service, hc *config.Host, opts ...Option) (*Server, error) { option := new(options) for _, opt := range opts { if err := opt(option); err != nil { return nil, err } } router, err := NewRouter(*option.fs, *option.debug, option.origins) if err != nil { return nil, err } s := &Server{ Router: router, bs: bs, as: as, hc: hc, addr: fmt.Sprintf("0.0.0.0:%d", *option.port), stripeWebhookSecret: "", } if option.stripeWebhookSecret != nil { s.stripeWebhookSecret = *option.stripeWebhookSecret } s.MountHandlers() return s, nil } func (s *Server) Start(ctx context.Context) { s.httpServer = &http.Server{ Addr: s.addr, Handler: s.Router, } go func() { if err := s.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("shutting down the server", slog.Any("error", err)) os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) defer signal.Stop(quit) select { case <-quit: case <-ctx.Done(): } shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := s.httpServer.Shutdown(shutdownCtx); err != nil { slog.Error("server shutdown failed", slog.Any("error", err)) os.Exit(1) } } func NewRouter(filesystem embed.FS, debug bool, origins []string) (*chi.Mux, error) { r := chi.NewRouter() _ = debug r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Compress(5)) r.Use(CachingMiddleware(0, "js", "css", "png", "ico")) if len(origins) == 0 { origins = []string{"*"} } r.Use(cors.Handler(cors.Options{ AllowedOrigins: origins, AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "HX-Request", "HX-Boosted", "api-key"}, ExposedHeaders: []string{"HX-Redirect"}, AllowCredentials: true, })) sentryHandler := sentryhttp.New(sentryhttp.Options{ Repanic: true, }) r.Use(sentryHandler.Handle) r.Use(SentryTracingMiddleware) assetsFS, err := fs.Sub(filesystem, "assets") if err != nil { return nil, fmt.Errorf("failed to load static assets: %w", err) } fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(assetsFS))) r.Handle("/static/*", fileServer) notFoundHandler, err := newHTTPErrorHandler(filesystem, http.StatusNotFound) if err != nil { return nil, fmt.Errorf("failed to setup not found handler: %w", err) } r.NotFound(notFoundHandler) return r, nil } func newHTTPErrorHandler(filesystem embed.FS, statusCode int) (http.HandlerFunc, error) { filePath := fmt.Sprintf("assets/html/HTTP%d.html", statusCode) page, err := fs.ReadFile(filesystem, filePath) if err != nil { return nil, fmt.Errorf("failed to read error page %s: %w", filePath, err) } return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(statusCode) if _, err := w.Write(page); err != nil { slog.Error("failed to write error page", slog.Any("error", err)) } }, nil }