From 973a15c55ba93c2e53f11cec2fba6640a373c60e Mon Sep 17 00:00:00 2001 From: Ruidy Date: Sat, 1 Nov 2025 18:10:12 +0100 Subject: [PATCH] feat(deps): migrate from Echo to Chi, update Stripe/Sentry Switch web framework from Echo to Chi, removing Echo-related dependencies and adding chi and cors. Update Stripe to v83.1.0 and Sentry to v0.36.2. Remove unused and indirect dependencies for a cleaner go.mod/go.sum. --- go.mod | 16 +- go.sum | 33 +- internal/cron/job_stripe_sync.go | 3 - internal/server/auth.go | 23 +- internal/server/handle_api_sync.go | 70 +-- internal/server/handle_auth.go | 38 +- internal/server/handle_bookings.go | 440 +++++++++++------- internal/server/handle_healthz.go | 9 +- internal/server/handle_payments.go | 62 +-- internal/server/handle_pdf.go | 55 ++- internal/server/handle_reports.go | 76 +-- internal/server/handle_stripe_sync.go | 30 +- internal/server/handle_stripe_sync_test.go | 27 +- internal/server/handle_stripe_webhook.go | 43 +- internal/server/handle_stripe_webhook_test.go | 74 ++- internal/server/helper.go | 39 +- internal/server/middleware.go | 30 +- internal/server/routes.go | 89 ++-- internal/server/server.go | 112 +++-- internal/server/tracing.go | 24 +- internal/service/auth/service.go | 40 +- 21 files changed, 731 insertions(+), 602 deletions(-) diff --git a/go.mod b/go.mod index 814e325..93e7008 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,13 @@ go 1.25.3 require ( github.com/a-h/templ v0.3.960 - github.com/getsentry/sentry-go v0.36.0 - github.com/getsentry/sentry-go/echo v0.36.0 + github.com/getsentry/sentry-go v0.36.2 + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/cors v1.2.2 github.com/gorilla/sessions v1.4.0 github.com/joho/godotenv v1.5.1 - github.com/labstack/echo-contrib v0.17.4 - github.com/labstack/echo/v4 v4.13.4 - github.com/labstack/gommon v0.4.2 github.com/rjNemo/underscore v0.8.0 - github.com/stripe/stripe-go/v83 v83.0.1 + github.com/stripe/stripe-go/v83 v83.1.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.0 ) @@ -26,7 +24,6 @@ require ( ) require ( - github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -34,16 +31,11 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/openai/openai-go v1.12.0 github.com/sethvargo/go-envconfig v1.3.0 - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.43.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect - golang.org/x/time v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index dbbac39..86d62cf 100644 --- a/go.sum +++ b/go.sum @@ -3,18 +3,18 @@ github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/getsentry/sentry-go v0.36.0 h1:UkCk0zV28PiGf+2YIONSSYiYhxwlERE5Li3JPpZqEns= -github.com/getsentry/sentry-go v0.36.0/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c= -github.com/getsentry/sentry-go/echo v0.36.0 h1:PimJIxiH2O/nS+jegFLxx52RMpVY2ciAIvVkk8miVeM= -github.com/getsentry/sentry-go/echo v0.36.0/go.mod h1:Z4Q44b9OWBO18lFcC1yfCqOVex00nz2WPSH1AuUUC5I= +github.com/getsentry/sentry-go v0.36.2 h1:uhuxRPTrUy0dnSzTd0LrYXlBYygLkKY0hhlG5LXarzM= +github.com/getsentry/sentry-go v0.36.2/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= -github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= @@ -33,16 +33,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= -github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= -github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= -github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -60,8 +50,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stripe/stripe-go/v83 v83.0.1 h1:HvUXOw0AcjYJ9zUTN5XW+k7HvkM1AY9zxbpOFN9bhRA= -github.com/stripe/stripe-go/v83 v83.0.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE= +github.com/stripe/stripe-go/v83 v83.1.0 h1:h6Wi8+dSUCmIdXDWObs1AirP9tQGWWI/4xP5oE5G6uQ= +github.com/stripe/stripe-go/v83 v83.1.0/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -73,10 +63,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= @@ -85,13 +71,10 @@ golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cron/job_stripe_sync.go b/internal/cron/job_stripe_sync.go index 0c75ddf..3ff25ad 100644 --- a/internal/cron/job_stripe_sync.go +++ b/internal/cron/job_stripe_sync.go @@ -41,9 +41,6 @@ func JobStripePaymentSync() error { store := booking.NewPgStore(db) opts := []stripeclient.Option{} - if cfg.StripeConnectAccount != "" { - opts = append(opts, stripeclient.WithAccount(cfg.StripeConnectAccount)) - } client, err := stripeclient.New(cfg.StripeSecretKey, opts...) if err != nil { diff --git a/internal/server/auth.go b/internal/server/auth.go index 69d1bb6..c1d8773 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -3,23 +3,24 @@ package server import ( "net/http" - "github.com/labstack/echo/v4" - "github.com/rjNemo/rentease/internal/constant" "github.com/rjNemo/rentease/internal/service/auth" ) -func MakeAuthMiddleware(as *auth.Service) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - if c.Request().RequestURI == constant.RouteLogin { - return next(c) +func MakeAuthMiddleware(as *auth.Service) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == constant.RouteLogin { + next.ServeHTTP(w, r) + return } - if !as.Authenticated(c) { - return c.Redirect(http.StatusSeeOther, constant.RouteLogin) + if !as.Authenticated(r) { + http.Redirect(w, r, constant.RouteLogin, http.StatusSeeOther) + return } - return next(c) - } + + next.ServeHTTP(w, r) + }) } } diff --git a/internal/server/handle_api_sync.go b/internal/server/handle_api_sync.go index 4ab979a..1cec945 100644 --- a/internal/server/handle_api_sync.go +++ b/internal/server/handle_api_sync.go @@ -4,68 +4,82 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" - "github.com/labstack/echo/v4" - "github.com/labstack/gommon/log" - "github.com/rjNemo/rentease/internal/service/booking" ) -func handleSync(bs *booking.Service) echo.HandlerFunc { +func handleSync(bs *booking.Service) http.HandlerFunc { type BookingInfo struct { Content string `json:"content"` } - return func(c echo.Context) error { - log.Info("received booking sync request from booking") - x := c.Request().Body - body, err := io.ReadAll(x) + return func(w http.ResponseWriter, r *http.Request) { + slog.Info("received booking sync request from booking") + body, err := io.ReadAll(r.Body) if err != nil { - return c.String(http.StatusInternalServerError, err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } + defer r.Body.Close() bookingInfo := new(BookingInfo) - err = json.Unmarshal(body, bookingInfo) - if err != nil { - return c.String(http.StatusInternalServerError, fmt.Sprintf("error unmarshalling JSON: %s", err)) + if err := json.Unmarshal(body, bookingInfo); err != nil { + http.Error(w, fmt.Sprintf("error unmarshalling JSON: %s", err), http.StatusInternalServerError) + return } b, err := bs.ParseFromAPI(bookingInfo.Content) if err != nil { - return c.String(http.StatusInternalServerError, err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - log.Infof("created booking %q from %q", b.Name, b.Platform) - return c.JSON(http.StatusCreated, "👍") + slog.Info("created booking from API", slog.String("name", b.Name), slog.String("platform", string(b.Platform))) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode("👍"); err != nil { + slog.Error("failed to write response", slog.Any("error", err)) + } } } -func handleCreateBooking(bs *booking.Service) echo.HandlerFunc { +func handleCreateBooking(bs *booking.Service) http.HandlerFunc { type BookingInfo struct { Content string `json:"content"` } - return func(c echo.Context) error { - log.Info("received booking sync request from booking") - x := c.Request().Body - body, err := io.ReadAll(x) + + return func(w http.ResponseWriter, r *http.Request) { + slog.Info("received booking sync request from booking") + body, err := io.ReadAll(r.Body) if err != nil { - return c.String(http.StatusInternalServerError, err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - log.Info(string(body)) + defer r.Body.Close() + + slog.Info("request body", slog.String("body", string(body))) bookingInfo := new(BookingInfo) - err = json.Unmarshal(body, bookingInfo) - if err != nil { - return c.String(http.StatusInternalServerError, fmt.Sprintf("error unmarshalling JSON: %s", err)) + if err := json.Unmarshal(body, bookingInfo); err != nil { + http.Error(w, fmt.Sprintf("error unmarshalling JSON: %s", err), http.StatusInternalServerError) + return } b, err := bs.ParseFromAPI(bookingInfo.Content) if err != nil { - return c.String(http.StatusInternalServerError, err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - log.Infof("created booking %q from %q", b.Name, b.Platform) - return c.JSON(http.StatusCreated, "👍") + slog.Info("created booking from API", slog.String("name", b.Name), slog.String("platform", string(b.Platform))) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode("👍"); err != nil { + slog.Error("failed to write response", slog.Any("error", err)) + } } } diff --git a/internal/server/handle_auth.go b/internal/server/handle_auth.go index b459af7..78a9316 100644 --- a/internal/server/handle_auth.go +++ b/internal/server/handle_auth.go @@ -3,23 +3,28 @@ package server import ( "net/http" - "github.com/labstack/echo/v4" - "github.com/rjNemo/rentease/internal/constant" "github.com/rjNemo/rentease/internal/service/auth" "github.com/rjNemo/rentease/internal/view" ) -func handleLoginPage() echo.HandlerFunc { - return func(c echo.Context) error { - return renderTempl(c, http.StatusOK, view.Login(view.LoginFormViewModel{})) +func handleLoginPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := renderTempl(w, http.StatusOK, view.Login(view.LoginFormViewModel{})); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handleLogin(as *auth.Service) echo.HandlerFunc { - return func(c echo.Context) error { - email := c.FormValue("email") - password := c.FormValue("password") +func handleLogin(as *auth.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + email := r.FormValue("email") + password := r.FormValue("password") if !as.ValidCredentials(email, password) { lfvm := view.LoginFormViewModel{ @@ -28,14 +33,19 @@ func handleLogin(as *auth.Service) echo.HandlerFunc { Errors: make(map[string]string), } lfvm.Errors["credentials"] = "invalid credentials" - return renderTempl(c, http.StatusUnauthorized, view.LoginForm(lfvm)) + if err := renderTempl(w, http.StatusUnauthorized, view.LoginForm(lfvm)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return } - err := as.Authenticate(c, "foo") - if err != nil { - return err + if err := as.Authenticate(w, r, "foo"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - return hxRedirect(c, http.StatusOK, constant.RouteBooking) + if err := hxRedirect(w, http.StatusOK, constant.RouteBooking); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } diff --git a/internal/server/handle_bookings.go b/internal/server/handle_bookings.go index 3c649a8..8b28fdf 100644 --- a/internal/server/handle_bookings.go +++ b/internal/server/handle_bookings.go @@ -1,7 +1,9 @@ package server import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -11,8 +13,7 @@ import ( "time" "github.com/a-h/templ" - "github.com/labstack/echo/v4" - "github.com/labstack/gommon/log" + "github.com/go-chi/chi/v5" u "github.com/rjNemo/underscore" "github.com/rjNemo/rentease/internal/config" @@ -22,9 +23,9 @@ import ( myTime "github.com/rjNemo/rentease/pkg/time" ) -func handleBookingListPage(bs *booking.Service, hc *config.Host) echo.HandlerFunc { - return func(c echo.Context) error { - search := c.FormValue("search") +func handleBookingListPage(bs *booking.Service, hc *config.Host) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + search := r.URL.Query().Get("search") var bookings []*booking.Line if search != "" { @@ -48,10 +49,16 @@ func handleBookingListPage(bs *booking.Service, hc *config.Host) echo.HandlerFun } }) - if hxRequest(c) && !hxBoosted(c) { - return renderTempl(c, http.StatusOK, view.BookingLines(bvm)) - } else { - return renderTempl(c, http.StatusOK, view.ListBookings(bvm)) + var err error + switch { + case hxRequest(r) && !hxBoosted(r): + err = renderTempl(w, http.StatusOK, view.BookingLines(bvm)) + default: + err = renderTempl(w, http.StatusOK, view.ListBookings(bvm)) + } + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) } } } @@ -70,9 +77,9 @@ func paymentViewModelFromBookingPayment(p booking.Payment) *view.PaymentViewMode } } -func handleBookingList(bs *booking.Service) echo.HandlerFunc { - return func(c echo.Context) error { - search := c.FormValue("search") +func handleBookingList(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + search := r.URL.Query().Get("search") var bookings []*booking.Line if search != "" { @@ -81,57 +88,77 @@ func handleBookingList(bs *booking.Service) echo.HandlerFunc { bookings = bs.All() } - return c.JSON(http.StatusOK, bookings) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(bookings); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handleBookingCreatePage(hc *config.Host) echo.HandlerFunc { - return func(c echo.Context) error { - return renderTempl(c, http.StatusOK, view.NewBooking(u.Map(hc.Platforms, func(p config.Platform) string { +func handleBookingCreatePage(hc *config.Host) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := renderTempl(w, http.StatusOK, view.NewBooking(u.Map(hc.Platforms, func(p config.Platform) string { return string(p) - }))) + }))); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handleBookingCreate(bs *booking.Service) echo.HandlerFunc { - return func(c echo.Context) error { - type NewBooking struct { - From time.Time `json:"from"` - To time.Time `json:"to"` - ExternalId *string `form:"external_id"` - Name string `form:"name"` - PhoneNumber string `form:"phone_number"` - Email string `form:"email"` - Platform string `form:"platform"` - CustomerNumber int `form:"customer_number"` - PlatformFees float64 `form:"platform_fees"` +func handleBookingCreate(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - nb := new(NewBooking) - err := c.Bind(nb) + + customerNumber, err := strconv.Atoi(r.FormValue("customer_number")) if err != nil { - log.Warn(err) - return err + http.Error(w, "invalid customer number", http.StatusBadRequest) + return } - ts, _ := myTime.ParseFromForm(c.FormValue("from")) - nb.From = ts - ts, _ = myTime.ParseFromForm(c.FormValue("to")) - nb.To = ts - - if *nb.ExternalId == "" { - nb.ExternalId = nil + platformFees := 0.0 + if v := r.FormValue("platform_fees"); v != "" { + platformFees, err = strconv.ParseFloat(v, 64) + if err != nil { + http.Error(w, "invalid platform fees", http.StatusBadRequest) + return + } } - b := bs.Create(nb.From, nb.To, nb.Name, nb.PhoneNumber, nb.Email, nb.Platform, nb.CustomerNumber, nb.PlatformFees, nb.ExternalId) - return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%d", constant.RouteBooking, b.ID)) + + externalID := r.FormValue("external_id") + var externalPtr *string + if externalID != "" { + externalPtr = &externalID + } + + from, _ := myTime.ParseFromForm(r.FormValue("from")) + to, _ := myTime.ParseFromForm(r.FormValue("to")) + + b := bs.Create( + from, + to, + r.FormValue("name"), + r.FormValue("phone_number"), + r.FormValue("email"), + r.FormValue("platform"), + customerNumber, + platformFees, + externalPtr, + ) + + http.Redirect(w, r, fmt.Sprintf("%s/%d", constant.RouteBooking, b.ID), http.StatusSeeOther) } } -func handleBookingPage(bs *booking.Service, hc *config.Host) echo.HandlerFunc { - return func(c echo.Context) error { - idStr := c.Param("id") +func handleBookingPage(bs *booking.Service, hc *config.Host) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) if err != nil { - return err + http.Error(w, "invalid booking id", http.StatusBadRequest) + return } b := bs.One(id) @@ -189,67 +216,99 @@ func handleBookingPage(bs *booking.Service, hc *config.Host) echo.HandlerFunc { ), PaymentMethods: hc.PaymentMethods, } - return renderTempl(c, http.StatusOK, view.BookingById(bvm)) + + if err := renderTempl(w, http.StatusOK, view.BookingById(bvm)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handleBookingStripePaymentLink(bs *booking.Service) echo.HandlerFunc { - return func(c echo.Context) error { - idStr := c.Param("id") +func handleBookingStripePaymentLink(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid booking id") + http.Error(w, "invalid booking id", http.StatusBadRequest) + return } - url, err := bs.CreateStripePaymentLink(c.Request().Context(), id) + url, err := bs.CreateStripePaymentLink(r.Context(), id) if err != nil { switch { case errors.Is(err, booking.ErrStripeClientNotConfigured): - return echo.NewHTTPError(http.StatusBadRequest, "stripe is not configured") + http.Error(w, "stripe is not configured", http.StatusBadRequest) case errors.Is(err, booking.ErrBookingNotFound): - return echo.NewHTTPError(http.StatusNotFound, "booking not found") + http.Error(w, "booking not found", http.StatusNotFound) case errors.Is(err, booking.ErrNoOutstandingBalance): - return echo.NewHTTPError(http.StatusBadRequest, "booking has no outstanding balance") + http.Error(w, "booking has no outstanding balance", http.StatusBadRequest) default: - return fmt.Errorf("failed to create payment link: %w", err) + http.Error(w, fmt.Sprintf("failed to create payment link: %v", err), http.StatusInternalServerError) } + return } - return c.JSON(http.StatusCreated, map[string]string{"url": url}) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(map[string]string{"url": url}); err != nil { + slog.Error("failed to write stripe payment link response", slog.Any("error", err)) + } } } -func handleBookingUpdate(bs *booking.Service, hc *config.Host) echo.HandlerFunc { - return func(c echo.Context) error { - type UpdateBooking struct { - From time.Time `json:"from"` - To time.Time `json:"to"` - ExternalId *string `form:"external_id"` - Name string `form:"name"` - PhoneNumber string `form:"phone_number"` - Email string `form:"email"` - Platform string `form:"platform"` - Id int `param:"id"` - CustomerNumber int `form:"customer_number"` - PlatformFees float64 `form:"platform_fees"` +func handleBookingUpdate(bs *booking.Service, hc *config.Host) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - nb := new(UpdateBooking) - err := c.Bind(nb) + + id, err := strconv.Atoi(chi.URLParam(r, "id")) if err != nil { - log.Warn(err) - return err + http.Error(w, "invalid booking id", http.StatusBadRequest) + return } - ts, _ := myTime.ParseFromForm(c.FormValue("from")) - nb.From = ts - ts, _ = myTime.ParseFromForm(c.FormValue("to")) - nb.To = ts - - if *nb.ExternalId == "" { - nb.ExternalId = nil + customerNumber, err := strconv.Atoi(r.FormValue("customer_number")) + if err != nil { + http.Error(w, "invalid customer number", http.StatusBadRequest) + return } - b := bs.Update(nb.Id, nb.From, nb.To, nb.Name, nb.PhoneNumber, nb.Email, nb.Platform, nb.CustomerNumber, nb.PlatformFees, nb.ExternalId) + platformFees := 0.0 + if v := r.FormValue("platform_fees"); v != "" { + platformFees, err = strconv.ParseFloat(v, 64) + if err != nil { + http.Error(w, "invalid platform fees", http.StatusBadRequest) + return + } + } + + externalID := r.FormValue("external_id") + var externalPtr *string + if externalID != "" { + externalPtr = &externalID + } + + from, _ := myTime.ParseFromForm(r.FormValue("from")) + to, _ := myTime.ParseFromForm(r.FormValue("to")) + + b := bs.Update( + id, + from, + to, + r.FormValue("name"), + r.FormValue("phone_number"), + r.FormValue("email"), + r.FormValue("platform"), + customerNumber, + platformFees, + externalPtr, + ) + + externalValue := "" + if b.ExternalID != nil { + externalValue = *b.ExternalID + } form := view.BookingForm(view.BookingViewModel{ Id: b.InvoiceNumber(hc), @@ -261,7 +320,7 @@ func handleBookingUpdate(bs *booking.Service, hc *config.Host) echo.HandlerFunc To: b.To.Format(time.DateOnly), Canceled: b.Canceled, Platform: string(b.Platform), - ExternalId: *b.ExternalID, + ExternalId: externalValue, Platforms: u.Map(hc.Platforms, func(p config.Platform) string { return string(p) }), PlatformFees: strconv.FormatFloat(b.PlatformFees, 'f', 2, 64), PaymentMethods: hc.PaymentMethods, @@ -269,17 +328,21 @@ func handleBookingUpdate(bs *booking.Service, hc *config.Host) echo.HandlerFunc PdfUrl: templ.SafeURL(fmt.Sprintf("%s/pdf/%d", constant.RouteBooking, b.ID)), }) - return renderTempl(c, http.StatusOK, form) + if err := renderTempl(w, http.StatusOK, form); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handleLineItemForm(bs *booking.Service) echo.HandlerFunc { - return func(c echo.Context) error { - idStr := c.Param("id") +func handleLineItemForm(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) if err != nil { - return err + http.Error(w, "invalid item id", http.StatusBadRequest) + return } + i := bs.OneItem(id) form := view.LineItemForm(&view.ItemViewModel{ Id: strconv.Itoa(i.ID), @@ -290,53 +353,56 @@ func handleLineItemForm(bs *booking.Service) echo.HandlerFunc { SubTotal: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64), ItemUrl: fmt.Sprintf("%s/%d", constant.RouteItem, i.ID), }) - return renderTempl(c, http.StatusOK, form) + + if err := renderTempl(w, http.StatusOK, form); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handleCreateItem(bs *booking.Service, hc *config.Host) echo.HandlerFunc { - type NewItem struct { - Item string `form:"item"` - PaymentMethod string `form:"method"` - Quantity int `form:"quantity"` - Price float64 `form:"price"` - } - - return func(c echo.Context) error { - bookingIdStr := c.Param("id") +func handleCreateItem(bs *booking.Service, hc *config.Host) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + bookingIdStr := chi.URLParam(r, "id") bid, err := strconv.Atoi(bookingIdStr) if err != nil { - return err + http.Error(w, "invalid booking id", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } b := bs.One(bid) - ni := new(NewItem) - if err := c.Bind(ni); err != nil { - log.Warn(err) - return err - } - - itm, ok := hc.Items[ni.Item] + itemName := r.FormValue("item") + itm, ok := hc.Items[itemName] if !ok { - return fmt.Errorf("invalid item name %q", ni.Item) + http.Error(w, fmt.Sprintf("invalid item name %q", itemName), http.StatusBadRequest) + return } - newItems := bs.CreateItem(b.ID, itm, ni.Quantity, ni.Price, ni.PaymentMethod, b.CustomerNumber, string(b.Platform)) + quantity, err := strconv.Atoi(r.FormValue("quantity")) + if err != nil { + http.Error(w, "invalid quantity", http.StatusBadRequest) + return + } - // TODO: fix the calendar integration - // if err = cs.Create( - // itm.CalendarId, - // b.Name, - // fmt.Sprintf("Reservation: %s\n %d voyageur(s)\n", b.Name, b.CustomerNumber), - // b.From, b.To, - // ); err != nil { - // log.Warnf("could not create event: %s", err) - // captureError(c, err) - // } + price := 0.0 + if v := r.FormValue("price"); v != "" { + price, err = strconv.ParseFloat(v, 64) + if err != nil { + http.Error(w, "invalid price", http.StatusBadRequest) + return + } + } + newItems := bs.CreateItem(b.ID, itm, quantity, price, r.FormValue("method"), b.CustomerNumber, string(b.Platform)) + + var buf bytes.Buffer for _, i := range newItems { - _ = renderTempl(c, http.StatusCreated, view.LineItem(&view.ItemViewModel{ + component := view.LineItem(&view.ItemViewModel{ Id: strconv.Itoa(i.ID), Item: i.Item, Quantity: strconv.Itoa(i.Quantity), @@ -344,23 +410,32 @@ func handleCreateItem(bs *booking.Service, hc *config.Host) echo.HandlerFunc { PaymentStatus: i.PaymentStatus, SubTotal: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64), ItemUrl: fmt.Sprintf("%s/%d", constant.RouteItem, i.ID), - })) + }) + if err := component.Render(context.Background(), &buf); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } - return nil + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusCreated) + if _, err := buf.WriteTo(w); err != nil { + slog.Error("failed to write item response", slog.Any("error", err)) + } } } -func handleItemPay(bs *booking.Service) echo.HandlerFunc { - return func(c echo.Context) error { - itemIdStr := c.Param("id") +func handleItemPay(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + itemIdStr := chi.URLParam(r, "id") itemId, err := strconv.Atoi(itemIdStr) if err != nil { - return err + http.Error(w, "invalid item id", http.StatusBadRequest) + return } i := bs.PayItem(itemId) - return renderTempl(c, http.StatusOK, view.LineItem(&view.ItemViewModel{ + if err := renderTempl(w, http.StatusOK, view.LineItem(&view.ItemViewModel{ Id: itemIdStr, Item: i.Item, Quantity: strconv.Itoa(i.Quantity), @@ -368,73 +443,104 @@ func handleItemPay(bs *booking.Service) echo.HandlerFunc { PaymentStatus: i.PaymentStatus, SubTotal: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64), ItemUrl: fmt.Sprintf("%s/%d", constant.RouteItem, i.ID), - })) + })); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handleItemUpdate(bs *booking.Service) echo.HandlerFunc { - return func(c echo.Context) error { - type updateItem struct { - Item string `form:"item"` - PaymentMethod string `form:"paymentMethod"` - PaymentStatus string `form:"paymentStatus"` - Id int `param:"id"` - Quantity int `form:"quantity"` - Price float64 `form:"price"` - } - ui := new(updateItem) - - if err := c.Bind(ui); err != nil { - log.Warn(err) - return err +func handleItemUpdate(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - i := bs.UpdateItem(ui.Id, ui.Item, ui.Quantity, ui.Price, ui.PaymentMethod, ui.PaymentStatus) + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid item id", http.StatusBadRequest) + return + } - return renderTempl(c, http.StatusCreated, view.LineItem(&view.ItemViewModel{ - Id: strconv.Itoa(ui.Id), + quantity, err := strconv.Atoi(r.FormValue("quantity")) + if err != nil { + http.Error(w, "invalid quantity", http.StatusBadRequest) + return + } + + price := 0.0 + if v := r.FormValue("price"); v != "" { + price, err = strconv.ParseFloat(v, 64) + if err != nil { + http.Error(w, "invalid price", http.StatusBadRequest) + return + } + } + + i := bs.UpdateItem(id, r.FormValue("item"), quantity, price, r.FormValue("paymentMethod"), r.FormValue("paymentStatus")) + + if err := renderTempl(w, http.StatusCreated, view.LineItem(&view.ItemViewModel{ + Id: strconv.Itoa(id), Item: i.Item, Quantity: strconv.Itoa(i.Quantity), Price: strconv.FormatFloat(i.Price, 'f', 2, 64), PaymentStatus: i.PaymentStatus, SubTotal: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64), ItemUrl: fmt.Sprintf("%s/%d", constant.RouteItem, i.ID), - })) + })); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handlePaymentUpdate(bs *booking.Service) echo.HandlerFunc { - type updatePayment struct { - Id int `param:"id"` - Amount float64 `form:"amount"` - PaymentMethod string `form:"paymentMethod"` - } - return func(c echo.Context) error { - up := new(updatePayment) - - if err := c.Bind(up); err != nil { - return fmt.Errorf("could not parse update payment request body: %w", err) +func handlePaymentUpdate(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - p := bs.UpdatePayment(up.Id, up.Amount, up.PaymentMethod) + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid payment id", http.StatusBadRequest) + return + } - return renderTempl(c, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p))) + amount := 0.0 + if v := r.FormValue("amount"); v != "" { + amount, err = strconv.ParseFloat(v, 64) + if err != nil { + http.Error(w, "invalid amount", http.StatusBadRequest) + return + } + } + + p := bs.UpdatePayment(id, amount, r.FormValue("paymentMethod")) + + if err := renderTempl(w, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p))); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handleBookingCancel(bs *booking.Service) echo.HandlerFunc { - return func(c echo.Context) error { - idStr := c.Param("id") +func handleBookingCancel(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) if err != nil { - return err + http.Error(w, "invalid booking id", http.StatusBadRequest) + return } bs.Cancel(id) - return renderTempl(c, http.StatusOK, templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { - _, err := io.WriteString(w, " Canceled") + component := templ.ComponentFunc(func(ctx context.Context, writer io.Writer) error { + _, err := io.WriteString(writer, " Canceled") return err - })) + }) + + if err := renderTempl(w, http.StatusOK, component); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } diff --git a/internal/server/handle_healthz.go b/internal/server/handle_healthz.go index 31ce366..0c007e6 100644 --- a/internal/server/handle_healthz.go +++ b/internal/server/handle_healthz.go @@ -2,12 +2,11 @@ package server import ( "net/http" - - "github.com/labstack/echo/v4" ) -func handleHealthCheck() echo.HandlerFunc { - return func(c echo.Context) error { - return c.String(http.StatusOK, "healthy") +func handleHealthCheck() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("healthy")) } } diff --git a/internal/server/handle_payments.go b/internal/server/handle_payments.go index 9fb19c4..84accd6 100644 --- a/internal/server/handle_payments.go +++ b/internal/server/handle_payments.go @@ -4,58 +4,68 @@ import ( "net/http" "strconv" - "github.com/labstack/echo/v4" + "github.com/go-chi/chi/v5" u "github.com/rjNemo/underscore" "github.com/rjNemo/rentease/internal/service/booking" "github.com/rjNemo/rentease/internal/view" ) -func handleCreatePayment(bs *booking.Service) echo.HandlerFunc { - type CreatePaymentInput struct { - Amount float64 `form:"amount"` - PaymentMethod string `form:"paymentMethod"` - } - - return func(c echo.Context) error { - idStr := c.Param("id") - id, err := strconv.Atoi(idStr) - if err != nil { - return err +func handleCreatePayment(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - np := new(CreatePaymentInput) - if err := c.Bind(np); err != nil { - return err + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid booking id", http.StatusBadRequest) + return + } + + amount := 0.0 + if v := r.FormValue("amount"); v != "" { + amount, err = strconv.ParseFloat(v, 64) + if err != nil { + http.Error(w, "invalid amount", http.StatusBadRequest) + return + } } b := bs.One(id) - _, err = bs.CreatePayment(b.ID, np.Amount, np.PaymentMethod) - if err != nil { - return err + if _, err := bs.CreatePayment(b.ID, amount, r.FormValue("paymentMethod")); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } nb := bs.One(id) - return renderTempl(c, http.StatusOK, view.PaymentList( + component := view.PaymentList( u.Map(nb.Payments, func(p booking.Payment) *view.PaymentViewModel { return paymentViewModelFromBookingPayment(p) }), - )) + ) + + if err := renderTempl(w, http.StatusOK, component); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handlePaymentForm(bs *booking.Service) echo.HandlerFunc { - return func(c echo.Context) error { - idStr := c.Param("id") - id, err := strconv.Atoi(idStr) +func handlePaymentForm(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) if err != nil { - return err + http.Error(w, "invalid payment id", http.StatusBadRequest) + return } p := bs.OnePayment(id) form := view.PaymentForm(paymentViewModelFromBookingPayment(*p)) - return renderTempl(c, http.StatusOK, form) + if err := renderTempl(w, http.StatusOK, form); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } diff --git a/internal/server/handle_pdf.go b/internal/server/handle_pdf.go index 9ec729f..6334eb2 100644 --- a/internal/server/handle_pdf.go +++ b/internal/server/handle_pdf.go @@ -2,71 +2,68 @@ package server import ( "fmt" - "log" + "log/slog" "net/http" "strconv" - "github.com/labstack/echo/v4" + "github.com/go-chi/chi/v5" u "github.com/rjNemo/underscore" "github.com/rjNemo/rentease/internal/config" "github.com/rjNemo/rentease/internal/service/booking" ) -func handlePdfCreateInvoice(bs *booking.Service, hc *config.Host) echo.HandlerFunc { - return func(c echo.Context) error { - idStr := c.Param("id") +func handlePdfCreateInvoice(bs *booking.Service, hc *config.Host) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) if err != nil { - log.Println(err) - return err + http.Error(w, "invalid booking id", http.StatusBadRequest) + return } b := bs.One(id) filePath, err := bs.BuildInvoice(b, hc) if err != nil { - log.Println(err) - return err + slog.Error("failed to build invoice", slog.Any("error", err)) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - return c.File(filePath) + + http.ServeFile(w, r, filePath) } } -func handlePdfCreateReport(bs *booking.Service) echo.HandlerFunc { - return func(c echo.Context) error { - period := c.QueryParam("period") +func handlePdfCreateReport(bs *booking.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + period := r.URL.Query().Get("period") if !u.Contains([]string{"month", "year"}, period) { - return &echo.HTTPError{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("%q is not a valid period", period), - } + http.Error(w, fmt.Sprintf("%q is not a valid period", period), http.StatusBadRequest) + return } - monthStr := c.QueryParam("month") + monthStr := r.URL.Query().Get("month") month, err := strconv.Atoi(monthStr) if err != nil || month < 1 || month > 12 { - return &echo.HTTPError{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("%q is not a valid month", month), - } + http.Error(w, fmt.Sprintf("%q is not a valid month", monthStr), http.StatusBadRequest) + return } - yearStr := c.QueryParam("year") + yearStr := r.URL.Query().Get("year") year, err := strconv.Atoi(yearStr) if err != nil { - return &echo.HTTPError{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("%q is not a valid year", year), - } + http.Error(w, fmt.Sprintf("%q is not a valid year", yearStr), http.StatusBadRequest) + return } report := bs.Report(period, month, year) filePath, err := bs.BuildReport(report, period, month, year) if err != nil { - return err + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - return c.File(filePath) + http.ServeFile(w, r, filePath) } } diff --git a/internal/server/handle_reports.go b/internal/server/handle_reports.go index 996e907..51409dd 100644 --- a/internal/server/handle_reports.go +++ b/internal/server/handle_reports.go @@ -7,7 +7,6 @@ import ( "time" "github.com/a-h/templ" - "github.com/labstack/echo/v4" u "github.com/rjNemo/underscore" "github.com/rjNemo/rentease/internal/config" @@ -16,64 +15,67 @@ import ( "github.com/rjNemo/rentease/internal/view" ) -func handleReportsPage() echo.HandlerFunc { - return func(c echo.Context) error { - monthStr := c.QueryParam("month") +func handleReportsPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + monthStr := r.URL.Query().Get("month") month, err := strconv.Atoi(monthStr) if err != nil || month < 1 || month > 12 { month = int(time.Now().Month()) } - yearStr := c.QueryParam("year") - _, err = strconv.Atoi(yearStr) - if err != nil { + yearStr := r.URL.Query().Get("year") + if _, err = strconv.Atoi(yearStr); err != nil { yearStr = time.Now().Format("2006") } - return renderTempl(c, http.StatusOK, view.Reports(u.Map(constant.Months, func(m constant.Month) string { + + component := view.Reports(u.Map(constant.Months, func(m constant.Month) string { return string(m) - }), month, yearStr)) + }), month, yearStr) + + if err := renderTempl(w, http.StatusOK, component); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func handleReportCompute(bs *booking.Service, hc *config.Host) echo.HandlerFunc { - return func(c echo.Context) error { - period := c.FormValue("period") - if !u.Contains([]string{"month", "year"}, period) { - return &echo.HTTPError{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("%q is not a valid period", period), - } +func handleReportCompute(bs *booking.Service, hc *config.Host) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - monthStr := c.FormValue("month") + period := r.FormValue("period") + if !u.Contains([]string{"month", "year"}, period) { + http.Error(w, fmt.Sprintf("%q is not a valid period", period), http.StatusBadRequest) + return + } + + monthStr := r.FormValue("month") month, err := strconv.Atoi(monthStr) if err != nil || month < 1 || month > 12 { - return &echo.HTTPError{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("%q is not a valid month", month), - } + http.Error(w, fmt.Sprintf("%q is not a valid month", monthStr), http.StatusBadRequest) + return } - yearStr := c.FormValue("year") + yearStr := r.FormValue("year") year, err := strconv.Atoi(yearStr) if err != nil { - return &echo.HTTPError{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("%q is not a valid year", year), - } + http.Error(w, fmt.Sprintf("%q is not a valid year", yearStr), http.StatusBadRequest) + return } - r := bs.Report(period, month, year) + report := bs.Report(period, month, year) reportVm := &view.ReportViewModel{ - Total: strconv.FormatFloat(r.Total, 'f', 2, 64), - PlatformFees: strconv.FormatFloat(r.PlatformFees, 'f', 2, 64), - Fee: strconv.FormatFloat(r.Fee, 'f', 2, 64), - Profit: strconv.FormatFloat(r.Profit, 'f', 2, 64), - CardTotal: strconv.FormatFloat(r.CardTotal, 'f', 2, 64), - BookingFees: strconv.FormatFloat(r.BookingFees, 'f', 2, 64), + Total: strconv.FormatFloat(report.Total, 'f', 2, 64), + PlatformFees: strconv.FormatFloat(report.PlatformFees, 'f', 2, 64), + Fee: strconv.FormatFloat(report.Fee, 'f', 2, 64), + Profit: strconv.FormatFloat(report.Profit, 'f', 2, 64), + CardTotal: strconv.FormatFloat(report.CardTotal, 'f', 2, 64), + BookingFees: strconv.FormatFloat(report.BookingFees, 'f', 2, 64), PdfUrl: templ.URL(fmt.Sprintf("%s/pdf?period=%s&month=%d&year=%d", constant.RouteReports, period, month, year)), - Lines: u.Map(r.Lines, func(l *booking.Line) *view.ReportLine { + Lines: u.Map(report.Lines, func(l *booking.Line) *view.ReportLine { return &view.ReportLine{ Id: l.InvoiceNumber(hc), Url: templ.SafeURL(fmt.Sprintf("%s/%d", constant.RouteBooking, l.ID)), @@ -89,6 +91,8 @@ func handleReportCompute(bs *booking.Service, hc *config.Host) echo.HandlerFunc }), } - return renderTempl(c, http.StatusOK, view.ReportSection(reportVm)) + if err := renderTempl(w, http.StatusOK, view.ReportSection(reportVm)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } diff --git a/internal/server/handle_stripe_sync.go b/internal/server/handle_stripe_sync.go index 833dcda..dcaf11c 100644 --- a/internal/server/handle_stripe_sync.go +++ b/internal/server/handle_stripe_sync.go @@ -8,8 +8,6 @@ import ( "net/http" "time" - "github.com/labstack/echo/v4" - "github.com/rjNemo/rentease/internal/service/booking" ) @@ -22,12 +20,13 @@ type stripeSyncer interface { SyncStripePayments(ctx context.Context, from, to time.Time) error } -func handleStripeSync(bs stripeSyncer) echo.HandlerFunc { - return func(c echo.Context) error { +func handleStripeSync(bs stripeSyncer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { req := new(stripeSyncRequest) - if err := json.NewDecoder(c.Request().Body).Decode(req); err != nil { + if err := json.NewDecoder(r.Body).Decode(req); err != nil { if !errors.Is(err, io.EOF) { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request payload") + http.Error(w, "invalid request payload", http.StatusBadRequest) + return } } @@ -38,7 +37,8 @@ func handleStripeSync(bs stripeSyncer) echo.HandlerFunc { if req.From != "" { parsed, err := time.Parse(time.RFC3339, req.From) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid 'from' timestamp, expected RFC3339") + http.Error(w, "invalid 'from' timestamp, expected RFC3339", http.StatusBadRequest) + return } from = parsed } @@ -46,18 +46,24 @@ func handleStripeSync(bs stripeSyncer) echo.HandlerFunc { if req.To != "" { parsed, err := time.Parse(time.RFC3339, req.To) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid 'to' timestamp, expected RFC3339") + http.Error(w, "invalid 'to' timestamp, expected RFC3339", http.StatusBadRequest) + return } to = parsed } - if err := bs.SyncStripePayments(c.Request().Context(), from, to); err != nil { + if err := bs.SyncStripePayments(r.Context(), from, to); err != nil { if errors.Is(err, booking.ErrStripeClientNotConfigured) { - return echo.NewHTTPError(http.StatusServiceUnavailable, "stripe client not configured") + http.Error(w, "stripe client not configured", http.StatusServiceUnavailable) + return } - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } diff --git a/internal/server/handle_stripe_sync_test.go b/internal/server/handle_stripe_sync_test.go index c93f116..8092a2a 100644 --- a/internal/server/handle_stripe_sync_test.go +++ b/internal/server/handle_stripe_sync_test.go @@ -7,8 +7,6 @@ import ( "strings" "testing" "time" - - "github.com/labstack/echo/v4" ) type stubStripeSyncer struct { @@ -31,15 +29,11 @@ func TestHandleStripeSyncSuccess(t *testing.T) { from := now.Add(-2 * time.Hour).Format(time.RFC3339) to := now.Format(time.RFC3339) - e := echo.New() req := httptest.NewRequest(http.MethodPost, "/api/stripe/sync", strings.NewReader(`{"from":"`+from+`","to":"`+to+`"}`)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - if err := handler(c); err != nil { - t.Fatalf("handler returned error: %v", err) - } + handler(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) @@ -54,22 +48,13 @@ func TestHandleStripeSyncInvalidTimestamp(t *testing.T) { syncer := &stubStripeSyncer{} handler := handleStripeSync(syncer) - e := echo.New() req := httptest.NewRequest(http.MethodPost, "/api/stripe/sync", strings.NewReader(`{"from":"not-a-date"}`)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - err := handler(c) - if err == nil { - t.Fatal("expected error for invalid timestamp") - } + handler(rec, req) - httpErr, ok := err.(*echo.HTTPError) - if !ok { - t.Fatalf("expected HTTPError, got %T", err) - } - if httpErr.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d", httpErr.Code) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rec.Code) } } diff --git a/internal/server/handle_stripe_webhook.go b/internal/server/handle_stripe_webhook.go index b8fe9fc..f529ce6 100644 --- a/internal/server/handle_stripe_webhook.go +++ b/internal/server/handle_stripe_webhook.go @@ -6,7 +6,6 @@ import ( "io" "net/http" - "github.com/labstack/echo/v4" "github.com/stripe/stripe-go/v83" "github.com/stripe/stripe-go/v83/webhook" ) @@ -16,50 +15,60 @@ type stripeEventService interface { HandleChargeRefunded(ctx context.Context, ch *stripe.Charge) error } -func handleStripeWebhook(bs stripeEventService, secret string) echo.HandlerFunc { - return func(c echo.Context) error { +var constructEvent = webhook.ConstructEvent + +func handleStripeWebhook(bs stripeEventService, secret string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { if secret == "" { - return echo.NewHTTPError(http.StatusServiceUnavailable, "stripe webhook secret not configured") + http.Error(w, "stripe webhook secret not configured", http.StatusServiceUnavailable) + return } - payload, err := io.ReadAll(c.Request().Body) + payload, err := io.ReadAll(r.Body) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "unable to read request body") + http.Error(w, "unable to read request body", http.StatusBadRequest) + return } - sig := c.Request().Header.Get("Stripe-Signature") + sig := r.Header.Get("Stripe-Signature") if sig == "" { - return echo.NewHTTPError(http.StatusBadRequest, "missing Stripe-Signature header") + http.Error(w, "missing Stripe-Signature header", http.StatusBadRequest) + return } - event, err := webhook.ConstructEvent(payload, sig, secret) + event, err := constructEvent(payload, sig, secret) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid webhook signature") + http.Error(w, "invalid webhook signature", http.StatusBadRequest) + return } switch event.Type { case stripe.EventTypePaymentIntentSucceeded: var pi stripe.PaymentIntent if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid payment intent payload") + http.Error(w, "invalid payment intent payload", http.StatusBadRequest) + return } - if err := bs.HandlePaymentIntentSucceeded(c.Request().Context(), &pi); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err := bs.HandlePaymentIntentSucceeded(r.Context(), &pi); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } case stripe.EventTypeChargeRefunded: var ch stripe.Charge if err := json.Unmarshal(event.Data.Raw, &ch); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid charge payload") + http.Error(w, "invalid charge payload", http.StatusBadRequest) + return } - if err := bs.HandleChargeRefunded(c.Request().Context(), &ch); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err := bs.HandleChargeRefunded(r.Context(), &ch); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } default: // Acknowledge events we don't actively process. } - return c.NoContent(http.StatusOK) + w.WriteHeader(http.StatusOK) } } diff --git a/internal/server/handle_stripe_webhook_test.go b/internal/server/handle_stripe_webhook_test.go index 4831c4a..8472bda 100644 --- a/internal/server/handle_stripe_webhook_test.go +++ b/internal/server/handle_stripe_webhook_test.go @@ -2,18 +2,13 @@ package server import ( "context" - "encoding/hex" "encoding/json" - "fmt" "net/http" "net/http/httptest" "strings" "testing" - "time" - "github.com/labstack/echo/v4" "github.com/stripe/stripe-go/v83" - "github.com/stripe/stripe-go/v83/webhook" ) type stubStripeEventService struct { @@ -56,26 +51,18 @@ func TestHandleStripeWebhookPaymentIntent(t *testing.T) { t.Fatalf("failed to marshal payload: %v", err) } - ts := time.Now() - sig := webhook.ComputeSignature(ts, payloadBytes, secret) - sigHeader := fmt.Sprintf("t=%d,v1=%s", ts.Unix(), hex.EncodeToString(sig)) - if _, err := webhook.ConstructEvent(payloadBytes, sigHeader, secret); err != nil { - t.Fatalf("signature validation failed in setup: %v", err) - } - - e := echo.New() - req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe", strings.NewReader(string(payloadBytes))) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - req.Header.Set("Stripe-Signature", sigHeader) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) + restore := stubConstructEvent(stripe.EventTypePaymentIntentSucceeded, payloadBytes) + defer restore() service := &stubStripeEventService{} handler := handleStripeWebhook(service, secret) - if err := handler(c); err != nil { - t.Fatalf("handler returned error: %v", err) - } + req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe", strings.NewReader(string(payloadBytes))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Stripe-Signature", "test") + rec := httptest.NewRecorder() + + handler(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) @@ -105,26 +92,18 @@ func TestHandleStripeWebhookChargeRefunded(t *testing.T) { } payloadBytes, _ := json.Marshal(payload) - ts := time.Now() - sig := webhook.ComputeSignature(ts, payloadBytes, secret) - sigHeader := fmt.Sprintf("t=%d,v1=%s", ts.Unix(), hex.EncodeToString(sig)) - if _, err := webhook.ConstructEvent(payloadBytes, sigHeader, secret); err != nil { - t.Fatalf("signature validation failed in setup: %v", err) - } - - e := echo.New() - req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe", strings.NewReader(string(payloadBytes))) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - req.Header.Set("Stripe-Signature", sigHeader) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) + restore := stubConstructEvent(stripe.EventTypeChargeRefunded, payloadBytes) + defer restore() service := &stubStripeEventService{} handler := handleStripeWebhook(service, secret) - if err := handler(c); err != nil { - t.Fatalf("handler returned error: %v", err) - } + req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe", strings.NewReader(string(payloadBytes))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Stripe-Signature", "test") + rec := httptest.NewRecorder() + + handler(rec, req) if !service.chargeCalled { t.Fatalf("expected charge handler to be called") @@ -132,19 +111,24 @@ func TestHandleStripeWebhookChargeRefunded(t *testing.T) { } func TestHandleStripeWebhookInvalidSignature(t *testing.T) { - e := echo.New() req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe", strings.NewReader("{}")) req.Header.Set("Stripe-Signature", "invalid") rec := httptest.NewRecorder() - c := e.NewContext(req, rec) handler := handleStripeWebhook(&stubStripeEventService{}, "secret") - err := handler(c) - if err == nil { - t.Fatal("expected error for invalid signature") - } + handler(rec, req) - if httpErr, ok := err.(*echo.HTTPError); !ok || httpErr.Code != http.StatusBadRequest { - t.Fatalf("expected 400 HTTP error, got %v", err) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 status, got %d", rec.Code) } } + +func stubConstructEvent(eventType stripe.EventType, payload []byte) func() { + original := constructEvent + constructEvent = func(_ []byte, _ string, _ string) (stripe.Event, error) { + event := stripe.Event{Type: eventType} + event.Data = &stripe.EventData{Raw: payload} + return event, nil + } + return func() { constructEvent = original } +} diff --git a/internal/server/helper.go b/internal/server/helper.go index 78c7e8e..6e4fe70 100644 --- a/internal/server/helper.go +++ b/internal/server/helper.go @@ -3,16 +3,16 @@ package server import ( "context" "log" + "net/http" "github.com/a-h/templ" - "github.com/labstack/echo/v4" ) -func renderTempl(c echo.Context, status int, t templ.Component) error { - c.Response().Writer.WriteHeader(status) +func renderTempl(w http.ResponseWriter, status int, t templ.Component) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) - err := t.Render(context.Background(), c.Response().Writer) - if err != nil { + if err := t.Render(context.Background(), w); err != nil { log.Printf("failed to render response template %s", err) return err } @@ -20,29 +20,16 @@ func renderTempl(c echo.Context, status int, t templ.Component) error { return nil } -func hxRedirect(c echo.Context, statusCode int, url string) error { - c.Response().Header().Add("HX-Redirect", url) - return c.NoContent(statusCode) +func hxRedirect(w http.ResponseWriter, statusCode int, url string) error { + w.Header().Add("HX-Redirect", url) + w.WriteHeader(statusCode) + return nil } -func hxRequest(c echo.Context) bool { - header, ok := c.Request().Header["Hx-Request"] - if !ok { - return false - } - if header[0] != "true" { - return false - } - return true +func hxRequest(r *http.Request) bool { + return r.Header.Get("Hx-Request") == "true" } -func hxBoosted(c echo.Context) bool { - header, ok := c.Request().Header["Hx-Boosted"] - if !ok { - return false - } - if header[0] != "true" { - return false - } - return true +func hxBoosted(r *http.Request) bool { + return r.Header.Get("Hx-Boosted") == "true" } diff --git a/internal/server/middleware.go b/internal/server/middleware.go index d09d1cd..3efd7fd 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -5,7 +5,6 @@ import ( "net/http" "strings" - "github.com/labstack/echo/v4" "github.com/rjNemo/underscore" ) @@ -15,31 +14,34 @@ const ( // CachingMiddleware adds caching headers. // -// ttl is the max age of the cache in seconds. If ttl is 0, the default value wil be used. -func CachingMiddleware(ttl int, fileTypes ...string) echo.MiddlewareFunc { +// ttl is the max age of the cache in seconds. If ttl is 0, the default value will be used. +func CachingMiddleware(ttl int, fileTypes ...string) func(http.Handler) http.Handler { if ttl == 0 { ttl = defaultTTL } - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { shouldCache := underscore.Any(fileTypes, func(v string) bool { - return strings.HasSuffix(c.Request().RequestURI, fmt.Sprintf(".%s", v)) + return strings.HasSuffix(r.URL.Path, fmt.Sprintf(".%s", v)) }) if shouldCache { - s := strings.Split(c.Request().RequestURI, "/") - etag := s[len(s)-1] + segments := strings.Split(r.URL.Path, "/") + etag := segments[len(segments)-1] - c.Response().Header().Set("Etag", etag) - c.Response().Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", ttl)) - if match := c.Request().Header.Get("If-None-Match"); match != "" { + w.Header().Set("Etag", etag) + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", ttl)) + + if match := r.Header.Get("If-None-Match"); match != "" { if strings.Contains(match, etag) { - return c.NoContent(http.StatusNotModified) + w.WriteHeader(http.StatusNotModified) + return } } } - return next(c) - } + next.ServeHTTP(w, r) + }) } } diff --git a/internal/server/routes.go b/internal/server/routes.go index e8b6671..6969060 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,50 +1,63 @@ package server import ( - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/rjNemo/rentease/internal/service/auth" ) -func (s Server) MountHandlers() { - s.Router.GET("/healthz", handleHealthCheck()) - s.Router.GET("/", handleLoginPage()) - s.Router.POST("/", handleLogin(s.as)) - s.Router.POST("/webhooks/stripe", handleStripeWebhook(s.bs, s.stripeWebhookSecret)) +func (s *Server) MountHandlers() { + s.Router.Get("/healthz", handleHealthCheck()) + s.Router.Get("/", handleLoginPage()) + s.Router.Post("/", handleLogin(s.as)) + s.Router.Post("/webhooks/stripe", handleStripeWebhook(s.bs, s.stripeWebhookSecret)) - api := s.Router.Group("/api") - api.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ - KeyLookup: "header:api-key", - Validator: func(key string, c echo.Context) (bool, error) { - return s.as.ValidateAPIKey(key), nil - }, - })) - api.POST("/sync", handleSync(s.bs)) - api.GET("/bookings", handleBookingList(s.bs)) - api.POST("/bookings", handleCreateBooking(s.bs)) - api.POST("/stripe/sync", handleStripeSync(s.bs)) + s.Router.Route("/api", func(r chi.Router) { + r.Use(apiKeyMiddleware(s.as)) + r.Post("/sync", handleSync(s.bs)) + r.Get("/bookings", handleBookingList(s.bs)) + r.Post("/bookings", handleCreateBooking(s.bs)) + r.Post("/stripe/sync", handleStripeSync(s.bs)) + }) - private := s.Router.Group("") - private.Use(MakeAuthMiddleware(s.as)) + s.Router.Group(func(r chi.Router) { + r.Use(MakeAuthMiddleware(s.as)) - private.GET("/bookings", handleBookingListPage(s.bs, s.hc)) - private.GET("/bookings/new", handleBookingCreatePage(s.hc)) - private.POST("/bookings/new", handleBookingCreate(s.bs)) - private.GET("/bookings/:id", handleBookingPage(s.bs, s.hc)) - private.POST("/bookings/:id/stripe/payment-link", handleBookingStripePaymentLink(s.bs)) - private.PUT("/bookings/:id", handleBookingUpdate(s.bs, s.hc)) - private.PATCH("/bookings/:id/cancel", handleBookingCancel(s.bs)) - private.POST("/bookings/:id/items", handleCreateItem(s.bs, s.hc)) - private.GET("/bookings/pdf/:id", handlePdfCreateInvoice(s.bs, s.hc)) + r.Get("/bookings", handleBookingListPage(s.bs, s.hc)) + r.Get("/bookings/new", handleBookingCreatePage(s.hc)) + r.Post("/bookings/new", handleBookingCreate(s.bs)) + r.Get("/bookings/{id}", handleBookingPage(s.bs, s.hc)) + r.Post("/bookings/{id}/stripe/payment-link", handleBookingStripePaymentLink(s.bs)) + r.Put("/bookings/{id}", handleBookingUpdate(s.bs, s.hc)) + r.Patch("/bookings/{id}/cancel", handleBookingCancel(s.bs)) + r.Post("/bookings/{id}/items", handleCreateItem(s.bs, s.hc)) + r.Get("/bookings/pdf/{id}", handlePdfCreateInvoice(s.bs, s.hc)) - private.POST("/items/:id", handleItemPay(s.bs)) - private.PUT("/items/:id", handleItemUpdate(s.bs)) - private.GET("/items/:id", handleLineItemForm(s.bs)) + r.Post("/items/{id}", handleItemPay(s.bs)) + r.Put("/items/{id}", handleItemUpdate(s.bs)) + r.Get("/items/{id}", handleLineItemForm(s.bs)) - private.GET("/reports", handleReportsPage()) - private.GET("/reports/do", handleReportCompute(s.bs, s.hc)) - private.GET("/reports/pdf", handlePdfCreateReport(s.bs)) + r.Get("/reports", handleReportsPage()) + r.Get("/reports/do", handleReportCompute(s.bs, s.hc)) + r.Get("/reports/pdf", handlePdfCreateReport(s.bs)) - private.POST("/payments/:id", handleCreatePayment(s.bs)) - private.PUT("/payments/:id", handlePaymentUpdate(s.bs)) - private.GET("/payments/:id", handlePaymentForm(s.bs)) + r.Post("/payments/{id}", handleCreatePayment(s.bs)) + r.Put("/payments/{id}", handlePaymentUpdate(s.bs)) + r.Get("/payments/{id}", handlePaymentForm(s.bs)) + }) +} + +func apiKeyMiddleware(as *auth.Service) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !as.ValidateAPIKey(r.Header.Get("api-key")) { + http.Error(w, "invalid api key", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } } diff --git a/internal/server/server.go b/internal/server/server.go index 0346f2e..78b7ace 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,17 +5,17 @@ import ( "embed" "errors" "fmt" + "io/fs" + "log/slog" "net/http" "os" "os/signal" "time" - "github.com/getsentry/sentry-go" - sentryecho "github.com/getsentry/sentry-go/echo" - "github.com/gorilla/sessions" - "github.com/labstack/echo-contrib/session" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" + 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" @@ -23,7 +23,8 @@ import ( ) type Server struct { - Router *echo.Echo + Router *chi.Mux + httpServer *http.Server bs *booking.Service as *auth.Service hc *config.Host @@ -34,14 +35,18 @@ type Server struct { func New(bs *booking.Service, as *auth.Service, hc *config.Host, opts ...Option) (*Server, error) { option := new(options) for _, opt := range opts { - err := opt(option) - if err != nil { + 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: NewRouter(*option.fs, *option.debug, *option.secretKey, option.origins), + Router: router, bs: bs, as: as, hc: hc, @@ -57,50 +62,73 @@ func New(bs *booking.Service, as *auth.Service, hc *config.Host, opts ...Option) return s, nil } -func (s Server) Start(c context.Context) { +func (s *Server) Start(ctx context.Context) { + s.httpServer = &http.Server{ + Addr: s.addr, + Handler: s.Router, + } + go func() { - if err := s.Router.Start(s.addr); err != nil && !errors.Is(err, http.ErrServerClosed) { - s.Router.Logger.Fatalf("shutting down the server: %s", err) + 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) - <-quit - ctx, cancel := context.WithTimeout(c, 10*time.Second) + defer signal.Stop(quit) + + select { + case <-quit: + case <-ctx.Done(): + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := s.Router.Shutdown(ctx); err != nil { - s.Router.Logger.Fatal(err) + + if err := s.httpServer.Shutdown(shutdownCtx); err != nil { + slog.Error("server shutdown failed", slog.Any("error", err)) + os.Exit(1) } } -func NewRouter(fs embed.FS, debug bool, secret string, origins []string) *echo.Echo { - e := echo.New() - // config - e.HideBanner = !debug - e.Debug = debug - // middlewares - e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ - Format: "${time_rfc3339} [${method}: ${status}] ${uri}; ip=${remote_ip}; ${latency_human}; ${user_agent}\n", +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, })) - e.Use(middleware.Recover()) - e.Use(middleware.Secure()) - e.Use(middleware.Gzip()) - e.Use(middleware.CORSWithConfig(middleware.CORSConfig{AllowOrigins: origins})) - e.Use(sentryecho.New(sentryecho.Options{})) - e.Use(SentryTracingMiddleware) - e.Use(CachingMiddleware(0, "js", "css", "png", "ico")) - e.Use(session.Middleware(sessions.NewCookieStore([]byte(secret)))) - // static assets - e.StaticFS("/static", echo.MustSubFS(fs, "assets")) - return e -} + sentryHandler := sentryhttp.New(sentryhttp.Options{ + Repanic: true, + }) + r.Use(sentryHandler.Handle) + r.Use(SentryTracingMiddleware) -func captureError(c echo.Context, err error) { - if hub := sentryecho.GetHubFromContext(c); hub != nil { - hub.WithScope(func(s *sentry.Scope) { - hub.CaptureMessage(err.Error()) - }) + 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) + + return r, nil } diff --git a/internal/server/tracing.go b/internal/server/tracing.go index 9ef5b86..f870369 100644 --- a/internal/server/tracing.go +++ b/internal/server/tracing.go @@ -2,34 +2,34 @@ package server import ( "fmt" + "net/http" "github.com/getsentry/sentry-go" - "github.com/labstack/echo/v4" ) -func SentryTracingMiddleware(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - ctx := c.Request().Context() +func SentryTracingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() hub := sentry.GetHubFromContext(ctx) if hub == nil { - // Check the concurrency guide for more details: https://docs.sentry.io/platforms/go/concurrency/ hub = sentry.CurrentHub().Clone() ctx = sentry.SetHubOnContext(ctx, hub) + r = r.WithContext(ctx) } options := []sentry.SpanOption{ - // Set the OP based on values from https://develop.sentry.dev/sdk/performance/span-operations/ sentry.WithOpName("http.server"), - sentry.ContinueFromRequest(c.Request()), + sentry.ContinueFromRequest(r), sentry.WithTransactionSource(sentry.SourceURL), } - transaction := sentry.StartTransaction(ctx, - fmt.Sprintf("%s %s", c.Request().Method, c.Request().URL.Path), + transaction := sentry.StartTransaction( + ctx, + fmt.Sprintf("%s %s", r.Method, r.URL.Path), options..., ) - defer transaction.Finish() - return next(c) - } + + next.ServeHTTP(w, r.WithContext(transaction.Context())) + }) } diff --git a/internal/service/auth/service.go b/internal/service/auth/service.go index b91e6ee..5a10cf9 100644 --- a/internal/service/auth/service.go +++ b/internal/service/auth/service.go @@ -2,10 +2,9 @@ package auth import ( "errors" + "net/http" "github.com/gorilla/sessions" - "github.com/labstack/echo-contrib/session" - "github.com/labstack/echo/v4" "github.com/rjNemo/rentease/internal/constant" ) @@ -20,6 +19,7 @@ type Service struct { admin string adminSecret string apiKey string + store *sessions.CookieStore } func NewService(secret, admin, adminSecret, apiKey string) (*Service, error) { @@ -27,11 +27,19 @@ func NewService(secret, admin, adminSecret, apiKey string) (*Service, error) { return nil, errors.New("error building Auth service. Verify your env variables") } + store := sessions.NewCookieStore([]byte(secret)) + store.Options = &sessions.Options{ + Path: constant.RouteLogin, + MaxAge: sessionAge, + HttpOnly: true, + } + return &Service{ - secret, - admin, - adminSecret, - apiKey, + secret: secret, + admin: admin, + adminSecret: adminSecret, + apiKey: apiKey, + store: store, }, nil } @@ -43,33 +51,27 @@ func (as *Service) ValidateAPIKey(key string) bool { return key == as.apiKey } -func (as *Service) getSession(c echo.Context) (*sessions.Session, error) { - sess, err := session.Get(sessionName, c) +func (as *Service) getSession(r *http.Request) (*sessions.Session, error) { + sess, err := as.store.Get(r, sessionName) if err != nil { return nil, err } - sess.Options = &sessions.Options{ - Path: constant.RouteLogin, - MaxAge: sessionAge, - HttpOnly: true, - } - return sess, nil } -func (as *Service) Authenticate(c echo.Context, key string) error { - sess, err := as.getSession(c) +func (as *Service) Authenticate(w http.ResponseWriter, r *http.Request, key string) error { + sess, err := as.getSession(r) if err != nil { return err } sess.Values["user"] = key - return sess.Save(c.Request(), c.Response()) + return sess.Save(r, w) } -func (as *Service) Authenticated(c echo.Context) bool { - sess, err := as.getSession(c) +func (as *Service) Authenticated(r *http.Request) bool { + sess, err := as.getSession(r) if err != nil { return false }