feat(deps): migrate from Echo to Chi, update Stripe/Sentry (#49)
Some checks failed
CI / checks (push) Has been cancelled

This commit is contained in:
Ruidy 2025-11-02 21:45:37 +01:00 committed by GitHub
parent 91a9a74750
commit 4bd47dc6e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 731 additions and 602 deletions

16
go.mod
View file

@ -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
)

33
go.sum
View file

@ -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=

View file

@ -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 {

View file

@ -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)
})
}
}

View file

@ -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))
}
}
}

View file

@ -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)
}
}
}

View file

@ -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, " <span>Canceled</span>")
component := templ.ComponentFunc(func(ctx context.Context, writer io.Writer) error {
_, err := io.WriteString(writer, " <span>Canceled</span>")
return err
}))
})
if err := renderTempl(w, http.StatusOK, component); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

View file

@ -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"))
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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 }
}

View file

@ -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"
}

View file

@ -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)
})
}
}

View file

@ -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)
})
}
}

View file

@ -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
}

View file

@ -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()))
})
}

View file

@ -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
}